Class: MCP::Client::OAuth::Flow

Inherits:
Object
  • Object
show all
Defined in:
lib/mcp/client/oauth/flow.rb

Overview

Internal orchestrator for the MCP OAuth 2.1 + PKCE + DCR authorization flow. Driven by ‘MCP::Client::HTTP` on a 401 response. The user-facing surface is `Provider`; this class consumes a Provider plus signal data extracted from the failing response (resource_metadata URL, scope challenge).

Defined Under Namespace

Classes: AuthorizationError, InvalidGrantError

Instance Method Summary collapse

Constructor Details

#initialize(provider:, http_client_factory: nil) ⇒ Flow

Returns a new instance of Flow.



26
27
28
29
# File 'lib/mcp/client/oauth/flow.rb', line 26

def initialize(provider:, http_client_factory: nil)
  @provider = provider
  @http_client_factory = http_client_factory || -> { default_http_client }
end

Instance Method Details

#refresh!(server_url:, resource_metadata_url: nil) ⇒ Object

Exchanges the saved ‘refresh_token` for a fresh access token (RFC 6749 Section 6). Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security checks before talking to it.

Returns ‘:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information, or when the token endpoint refuses the refresh request. www.rfc-editor.org/rfc/rfc6749#section-6

Raises:



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/mcp/client/oauth/flow.rb', line 103

def refresh!(server_url:, resource_metadata_url: nil)
  refresh_token = read_token("refresh_token")
  raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token

  client_info = @provider.client_information
  unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
    raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
  end

  if 
    ensure_secure_url!(, label: "WWW-Authenticate resource_metadata URL")
  end

  prm = (
    server_url: server_url,
    resource_metadata_url: ,
  )
  authorization_server = first_authorization_server(prm)
  ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")

  resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])

   = (issuer_url: authorization_server)
  ensure_issuer_matches!(expected: authorization_server, returned: ["issuer"])
  ensure_secure_endpoints!()

  new_tokens = exchange_refresh_token(
    as_metadata: ,
    client_info: client_info,
    refresh_token: refresh_token,
    resource: resource,
  )

  @provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token))
  :refreshed
end

#run!(server_url:, resource_metadata_url: nil, scope: nil) ⇒ Object

Runs the full discovery, registration, authorization, and token exchange flow. On success, persists tokens via the provider and returns ‘:authorized`.

Raises:



33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/mcp/client/oauth/flow.rb', line 33

def run!(server_url:, resource_metadata_url: nil, scope: nil)
  # The `resource_metadata` URL ships in `WWW-Authenticate` and is the very
  # first thing we contact in the OAuth flow, so it has to clear the same
  # Communication Security bar as the OAuth endpoints downstream.
  if 
    ensure_secure_url!(, label: "WWW-Authenticate resource_metadata URL")
  end

  prm = (
    server_url: server_url,
    resource_metadata_url: ,
  )
  authorization_server = first_authorization_server(prm)
  ensure_secure_url!(authorization_server, label: "PRM `authorization_servers` entry")

  # Per RFC 8707 + MCP authorization, the canonical MCP server URI is sent on
  # both the authorization and token requests. When PRM advertises a `resource`,
  # it MUST identify the same MCP server we are talking to; otherwise we are
  # being redirected to credentials minted for a different audience.
  resource = canonical_resource(server_url: server_url, prm_resource: prm["resource"])

   = (issuer_url: authorization_server)
  ensure_issuer_matches!(expected: authorization_server, returned: ["issuer"])
  ensure_secure_endpoints!()
  ensure_pkce_supported!()

  client_info = ensure_client_registered(as_metadata: )

  effective_scope = resolve_scope(scope: scope, prm: prm)
  pkce = PKCE.generate
  state = SecureRandom.urlsafe_base64(32)

  authorization_url = build_authorization_url(
    as_metadata: ,
    client_id: client_info_required_value(client_info, "client_id"),
    scope: effective_scope,
    state: state,
    code_challenge: pkce[:code_challenge],
    resource: resource,
  )

  @provider.redirect_handler.call(authorization_url)
  code, returned_state = Array(@provider.callback_handler.call)
  raise AuthorizationError, "Authorization callback did not return an authorization code." unless code

  unless states_match?(returned_state, state)
    raise AuthorizationError, "OAuth state mismatch (CSRF protection)."
  end

  tokens = exchange_authorization_code(
    as_metadata: ,
    client_info: client_info,
    code: code,
    code_verifier: pkce[:code_verifier],
    resource: resource,
  )

  @provider.save_tokens(tokens)
  :authorized
end