Skip to content

Support a two-leg authorization code flow for web-hosted clients - #573

Open
atesgoral wants to merge 1 commit into
modelcontextprotocol:mainfrom
atesgoral:feat/two-leg-authorization-code-flow
Open

atesgoral wants to merge 1 commit into
modelcontextprotocol:mainfrom
atesgoral:feat/two-leg-authorization-code-flow

Conversation

@atesgoral

@atesgoral atesgoral commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Closes #572.

Motivation and Context

OAuth::Flow#run! runs the whole authorization code flow in one call and blocks on callback_handler for 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 and state exist only on the first request's stack, and assembling the flow outside the SDK means reimplementing registration, issuer binding, iss validation, and the token exchange that are private to Flow.

This follows the TypeScript SDK's shape (auth() returning 'REDIRECT', then finishAuth()), which Provider was already modeled on, and adds the Rust SDK's binding of pending state to state (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: result
Loading

Without callback_handler: each leg returns, and the pending authorization lives in storage between 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 token
Loading
  • callback_handler: becomes optional. Without it, Flow#run! saves a pending authorization in storage keyed by state, calls redirect_handler, and returns :redirect; the flow's authorization_url reader returns the URL. Providers that pass callback_handler: behave exactly as before.
  • storage gains three optional methods, save_pending_authorization(state, pending), pending_authorization(state), and delete_pending_authorization(state), required only without callback_handler (Provider::PendingAuthorizationStorageError otherwise). InMemoryStorage implements 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, and created_at. The client secret is not copied into it.
  • Flow#finish!(server_url:, callback_params:) takes the redirect's whole query, so the presence of iss comes from the query itself. It:
    1. looks the pending authorization up by state before any request (unknown, used, malformed, or oversized state is refused), and discards and refuses one older than pending_authorization_max_age (new Provider keyword, default 600 seconds);
    2. requires server_url to name the server the authorization began with, and re-checks the recorded endpoints, since the metadata made a round trip through application storage;
    3. validates iss against the recorded issuer, and requires it when the recorded metadata advertises support, before consuming the entry, so a forged callback with a valid state cannot burn the real verifier;
    4. consumes the entry, then surfaces an error response (bounded like token endpoint errors) only after the issuer check;
    5. redeems the code at the recorded token endpoint with the recorded client registration (a registration replaced in the meantime is refused), resource, and redirect_uri, without running discovery again (SEP-2352).
  • MCP::Client::HTTP with such a provider raises Flow::AuthorizationPendingError after the first leg instead of retrying, on both the 401 and the 403 insufficient_scope step-up paths. It sits outside AuthorizationError so the refresh fallback does not catch it, and it exposes authorization_url while keeping the URL (which carries state) 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 on Flow only. A callback endpoint usually has no live transport, so I did not add a convenience on MCP::Client::HTTP; happy to add one if we want parity with TypeScript's transport.finishAuth.
  • The default maximum age of a pending authorization is 600 seconds.

How Has This Been Tested?

  • New test/mcp/client/oauth/two_leg_flow_test.rb drives 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/Array state, expiry, a different MCP server, a replaced registration, a CIMD client_id, tampered recorded endpoints, a malformed entry, a missing code, and a one-leg provider. On iss, it checks that a mismatch keeps the entry (and the legitimate callback then completes), that a missing iss is refused when advertised, that an error response is surfaced after a matching iss, and that an error response with a mismatched iss is not surfaced.
  • test/mcp/client/oauth/http_oauth_test.rb: a 401 with a two-leg provider raises AuthorizationPendingError without retrying, and the next request after finish! succeeds with the stored token.
  • test/mcp/client/oauth/provider_test.rb: optional callback_handler, the storage requirement, pending_authorization_max_age validation, and InMemoryStorage.
  • Mutation check: moving the entry deletion ahead of the iss check fails three of the new tests.
  • bundle exec rake test (1913 runs, 0 failures) and bundle exec rake rubocop pass 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

  • 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

@atesgoral
atesgoral force-pushed the feat/two-leg-authorization-code-flow branch 3 times, most recently from 17cd95c to 59dfc68 Compare September 23, 2026 21:09
@atesgoral
atesgoral marked this pull request as ready for review September 24, 2026 01:23
@atesgoral
atesgoral requested a review from koic September 24, 2026 01:23
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.
@atesgoral
atesgoral force-pushed the feat/two-leg-authorization-code-flow branch from 587670e to e42bc7d Compare September 24, 2026 01:31
iss_provided: true,
)

@provider.delete_pending_authorization(state)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"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?

This branch has not been deployed

No deployments
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.

Support a two-leg authorization code flow for web-hosted clients

2 participants