Re-run OAuth flow on 403 insufficient_scope (step-up)

## Motivation and Context

The MCP authorization specification (2025-11-25 scope-selection-strategy)
and RFC 6750 Section 3.1 describe OAuth 2.0 step-up authentication:
the client already holds a valid access token, but the server returns a 403
with `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`
to demand a higher-privileged scope for a specific operation.
The client is expected to run a fresh authorization request with
the escalated scope and retry.

Until now the Ruby SDK only ran the OAuth flow on 401 responses.
A 403 surfaced unchanged as a `RequestHandlerError` and the operation
that needed the escalated scope failed permanently, even with a valid token
in storage. This lined up with the `auth/scope-step-up` conformance
scenario being listed in `conformance/expected_failures.yml`.

The change adds:

- `MCP::Client::HTTP#send_request` recognises a 403 carrying
  an `insufficient_scope` Bearer challenge as a step-up signal,
  re-runs a full authorization request with the scope from the challenge,
  and retries the original request. Retries are capped at
  one per `send_request` call so a server that repeatedly demands more
  scope cannot loop the transport.
- `run_step_up_flow!` deliberately bypasses the refresh-token path that
  `run_oauth_flow!` uses on 401. Refreshing would re-issue the same
  scope set the existing token already has and the server already
  rejected. Only a fresh `authorization_code` grant with the escalated
  scope can succeed.
- A plain 403 with no `insufficient_scope` challenge continues to
  surface as `RequestHandlerError` with `error_type: :forbidden`.
- `conformance/client.rb` invokes a tool for the `auth/scope-step-up`
  scenario; the scenario's authorization middleware only requires
  the escalated scope on `tools/call`, so the previous `tools/list`-only
  client could never trigger the second authorization.
- `auth/scope-step-up` is removed from `conformance/expected_failures.yml`.

Resolves #383.

## How Has This Been Tested?

New `HTTPOAuthTest` cases cover: the 403 insufficient_scope path
re-authorizes with the escalated scope from the challenge and retries
the original request with the new bearer token; a plain 403 without
the `insufficient_scope` challenge is surfaced as
`error_type: :forbidden` with no OAuth side effects; repeated
`insufficient_scope` responses retry at most once and then surface the 403;
a provider with a saved `refresh_token` still goes through
the full authorization request rather than exchanging the refresh token,
because the existing scope set is precisely what was rejected.

Conformance: `auth/scope-step-up` now passes 22/22 with no warnings.
`bundle exec rake test`, `bundle exec rake rubocop`, and
`bundle exec rake conformance` are all green.

## Breaking Changes

None. Behaviour on 401 is unchanged. A 403 without the `insufficient_scope`
Bearer challenge still raises the same `RequestHandlerError` as before.
The new retry only fires when both the WWW-Authenticate header signals step-up
and the transport was constructed with an OAuth provider.
This commit is contained in:
Koichi ITO 2026-05-28 13:06:32 +09:00
commit a407cea086
5 changed files with 493 additions and 18 deletions

View file

@ -1913,6 +1913,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
- On a `403 Forbidden` whose `WWW-Authenticate` header carries `error="insufficient_scope"` (OAuth 2.0 step-up, RFC 6750 Section 3.1 and the MCP scope-selection-strategy),
run a fresh authorization request for the union of the currently granted scope and the scope named in the challenge, then retry the failed request once.
The refresh path is bypassed because refreshing would re-issue the same scope set the server just rejected. A `403` without that challenge is surfaced unchanged.
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
`scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
`offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.

View file

@ -116,7 +116,16 @@ when %r|\Aauth/|
# Auth-only scenarios: the protocol-level checks (PRM/AS metadata, DCR, PKCE, token usage)
# are observed by the conformance server during `connect` and the subsequent request below.
# Listing tools forces a second authenticated MCP request so the bearer token usage check fires.
client.tools
tools = client.tools
# `auth/scope-step-up` only fires its escalation 403 on `tools/call`, not `tools/list`,
# so the client must actually invoke a tool to drive the second authorization request
# the scenario asserts on.
if scenario == "auth/scope-step-up"
tool = tools.find { |t| t.name == "test-tool" } || tools.first
abort("No tool exposed by conformance server for #{scenario}") unless tool
client.call_tool(tool: tool, arguments: {})
end
else
abort("Unknown or unsupported scenario: #{scenario}")
end

View file

@ -5,7 +5,6 @@ client:
# TODO: Elicitation not implemented in Ruby client.
- elicitation-sep1034-client-defaults
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
- auth/scope-step-up
- auth/2025-03-26-oauth-metadata-backcompat
- auth/2025-03-26-oauth-endpoint-fallback
- auth/client-credentials-jwt

View file

@ -185,6 +185,7 @@ module MCP
method = request[:method] || request["method"]
params = request[:params] || request["params"]
oauth_retried = false
step_up_retried = false
begin
response = client.post("", request, session_headers)
@ -217,6 +218,19 @@ module MCP
original_error: e,
)
rescue Faraday::ForbiddenError => e
# OAuth 2.0 step-up: a 403 carrying `error="insufficient_scope"` in
# the Bearer challenge means the existing access token is valid
# but lacks scopes the server now requires for this operation.
# Re-run the full authorization flow with the escalated scope from
# the challenge and retry once. A plain 403 without the challenge is
# surfaced unchanged.
if @oauth && !step_up_retried && insufficient_scope_challenge?(e)
step_up_retried = true
run_step_up_flow!(forbidden_error: e)
retry
end
raise RequestHandlerError.new(
"You are forbidden to make #{method} requests",
{ method: method, params: params },
@ -337,16 +351,63 @@ module MCP
#
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#error-handling
def run_oauth_flow!(unauthorized_error:)
response = unauthorized_error.response || {}
response_headers = response[:headers] || {}
www_authenticate = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
params = MCP::Client::OAuth::Discovery.parse_www_authenticate(www_authenticate)
params = parse_www_authenticate_from_error(unauthorized_error)
flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
return
end
return if attempt_refresh(flow: flow, resource_metadata_url: params["resource_metadata"])
run_full_authorization_flow!(flow: flow, params: params)
end
# Drives a full Authorization Code + PKCE flow without first attempting
# to refresh the access token. Used for the MCP scope-selection-strategy
# step-up path: the provider already holds a valid access token,
# but the server returned a 403 with
# `WWW-Authenticate: ... error="insufficient_scope", scope="..."`
# per RFC 6750 Section 3.1. Refreshing the existing token would re-issue
# the same scope set the server already rejected, so the SDK must run
# a fresh authorization request. The request asks for the union of
# the currently granted scope and the newly demanded scope; otherwise
# the caller would lose previously held scopes and trigger another step-up
# on the next operation that needs them.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#scope-selection-strategy
def run_step_up_flow!(forbidden_error:)
params = parse_www_authenticate_from_error(forbidden_error)
flow = MCP::Client::OAuth::Flow.new(provider: @oauth)
params = params.merge("scope" => escalated_step_up_scope(params["scope"]))
run_full_authorization_flow!(flow: flow, params: params)
end
# Returns the space-separated union of the currently granted scope (read
# from the stored token response per RFC 6749 Section 5.1) and the scope
# demanded by the step-up challenge. Duplicates are collapsed; order
# follows first appearance so existing scopes precede the newly added
# ones. Returns `nil` when neither side carries a scope so
# `build_authorization_url` omits the `scope` parameter entirely.
def escalated_step_up_scope(challenge_scope)
tokens = @oauth.tokens
granted = tokens.is_a?(Hash) ? (tokens["scope"] || tokens[:scope]) : nil
scopes = [granted, challenge_scope].compact.flat_map { |scope| scope.to_s.split }.uniq
scopes.empty? ? nil : scopes.join(" ")
end
# True when the response on `forbidden_error` carries a Bearer challenge
# with `error="insufficient_scope"` per RFC 6750 Section 3.1 and the MCP
# scope-selection-strategy section. A 403 without that signal is not a
# step-up challenge and must not trigger re-authorization.
def insufficient_scope_challenge?(forbidden_error)
parse_www_authenticate_from_error(forbidden_error)["error"] == "insufficient_scope"
end
def parse_www_authenticate_from_error(error)
response = error.response || {}
response_headers = response[:headers] || {}
header = response_headers["www-authenticate"] || response_headers["WWW-Authenticate"]
MCP::Client::OAuth::Discovery.parse_www_authenticate(header)
end
def run_full_authorization_flow!(flow:, params:)
# Use the URL snapshotted at `initialize` time so a post-construction
# mutation of `@url` cannot redirect PRM/AS discovery and the authorize
# URL to an attacker-controlled host.

View file

@ -22,15 +22,6 @@ module MCP
WebMock.reset!
end
def build_provider
Provider.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) {},
callback_handler: -> { ["code", "state"] },
)
end
def test_send_request_runs_oauth_flow_on_401_and_retries_with_bearer_token
# First POST: 401 with WWW-Authenticate. Second POST (with Bearer): 200.
stub_request(:post, @mcp_url)
@ -110,6 +101,325 @@ module MCP
assert_equal("test-token-after-flow", provider.access_token)
end
def test_send_request_runs_step_up_flow_on_403_insufficient_scope
# First call with the initial token: 403 carrying an `insufficient_scope`
# Bearer challenge with the escalated scope. The transport must re-run
# a full authorization (NOT a refresh) with the escalated scope and
# retry the original request.
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer initial-token" },
).to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer escalated-token" },
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_step_up_authorization_server
provider = build_step_up_provider
transport = HTTP.new(url: @mcp_url, oauth: provider)
response = transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: { name: "x" } },
)
assert_equal({ "ok" => true }, response["result"])
assert_equal("escalated-token", provider.access_token)
# The fresh authorization-code grant ran (refresh path was bypassed).
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "authorization_code" &&
form["code"] == "test-auth-code"
end
# And the authorization request the SDK handed to the redirect
# handler carried the escalated scope from the challenge.
authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h
assert_equal("mcp:basic mcp:write", authorization_query["scope"])
end
def test_send_request_step_up_unions_existing_scope_with_challenge_scope
# MCP step-up: the re-authorization request must ask for the union
# of the previously granted scope and the newly demanded scope.
# Otherwise the new token would lack permissions the caller had
# before, triggering another step-up the next time they are used.
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer initial-token" })
.to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer escalated-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_step_up_authorization_server
provider = build_step_up_provider
# The provider already holds a token granted for `mcp:read`.
provider.save_tokens(
"access_token" => "initial-token",
"token_type" => "Bearer",
"scope" => "mcp:read",
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h
requested = authorization_query["scope"].split
assert_includes(requested, "mcp:read", "must preserve previously granted scope")
assert_includes(requested, "mcp:write", "must add the newly demanded scope")
assert_equal(requested.uniq.length, requested.length, "must not duplicate scopes")
end
def test_send_request_does_not_retry_plain_403
# A 403 without an `insufficient_scope` Bearer challenge is a hard
# forbidden, not a step-up signal. Surface it as a `RequestHandlerError`
# rather than tripping the OAuth flow.
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer initial-token" },
).to_return(
status: 403, body: "",
)
provider = build_step_up_provider
transport = HTTP.new(url: @mcp_url, oauth: provider)
error = assert_raises(MCP::Client::RequestHandlerError) do
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
end
assert_equal(:forbidden, error.error_type)
assert_not_requested(:get, "#{@auth_base}/.well-known/oauth-authorization-server")
end
def test_send_request_does_not_loop_on_repeated_insufficient_scope
# The server keeps demanding more scope after every authorization.
# The transport retries the step-up flow at most once per
# `send_request`, then surfaces the 403 to the caller.
stub_request(:post, @mcp_url).to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:everything", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_step_up_authorization_server
provider = build_step_up_provider
transport = HTTP.new(url: @mcp_url, oauth: provider)
error = assert_raises(MCP::Client::RequestHandlerError) do
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
end
assert_equal(:forbidden, error.error_type)
end
def test_send_request_step_up_bypasses_refresh_token
# The provider has a refresh_token, but step-up MUST run a fresh
# authorization request with the escalated scope, not exchange the
# refresh_token. Refreshing would mint a new access token with the
# same scopes as the current one, which already failed.
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer initial-token" },
).to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer escalated-token" },
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_step_up_authorization_server
provider = build_step_up_provider
provider.save_tokens(
"access_token" => "initial-token",
"refresh_token" => "saved-rt",
"token_type" => "Bearer",
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
# The token endpoint was only called for the fresh
# `authorization_code` grant, never for `refresh_token`.
assert_not_requested(:post, "#{@auth_base}/token") do |req|
URI.decode_www_form(req.body).to_h["grant_type"] == "refresh_token"
end
end
def test_send_request_step_up_appends_offline_access_when_advertised
# Integration of step-up with the SEP-2207 offline_access logic:
# the re-authorization request unions the existing and challenge scopes,
# then -- because the AS advertises `offline_access` and the client
# declared the `refresh_token` grant -- `offline_access` is appended
# so the escalated token can itself be refreshed later.
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer initial-token" })
.to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer escalated-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_step_up_authorization_server(
extra_as_metadata: { scopes_supported: ["mcp:read", "mcp:write", "offline_access"] },
)
provider = build_step_up_provider(grant_types: ["authorization_code", "refresh_token"])
provider.save_tokens(
"access_token" => "initial-token",
"token_type" => "Bearer",
"scope" => "mcp:read",
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
requested = URI.decode_www_form(provider.last_authorization_url.query).to_h["scope"].split
assert_includes(requested, "mcp:read", "must preserve previously granted scope")
assert_includes(requested, "mcp:write", "must add the challenged scope")
assert_includes(requested, "offline_access", "must append offline_access when advertised")
end
def test_send_request_step_up_strips_offline_access_when_not_advertised
# Mirror of the previous test: if the AS does NOT advertise
# `offline_access`, the step-up request must not carry it even when
# the existing token's scope happened to include it
# (SEP-2207 MUST-NOT, enforced by `normalize_offline_access_scope`).
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer initial-token" })
.to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer escalated-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
# AS advertises only resource scopes, no offline_access.
stub_step_up_authorization_server(
extra_as_metadata: { scopes_supported: ["mcp:read", "mcp:write"] },
)
provider = build_step_up_provider(grant_types: ["authorization_code", "refresh_token"])
provider.save_tokens(
"access_token" => "initial-token",
"token_type" => "Bearer",
"scope" => "mcp:read offline_access",
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
requested = URI.decode_www_form(provider.last_authorization_url.query).to_h["scope"].split
assert_includes(requested, "mcp:read")
assert_includes(requested, "mcp:write")
refute_includes(requested, "offline_access", "must strip offline_access when the AS does not advertise it")
end
def test_send_request_step_up_uses_cimd_client_id
# Integration of step-up with Client ID Metadata Documents:
# when the provider is CIMD-configured and the AS advertises CIMD support,
# the step-up re-authorization uses the CIMD URL as `client_id` and skips
# Dynamic Client Registration, just like the initial flow.
cimd_url = "https://app.example.com/client-metadata.json"
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer initial-token" })
.to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer escalated-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_step_up_authorization_server(
extra_as_metadata: { client_id_metadata_document_supported: true },
)
provider = build_step_up_provider(client_id_metadata_document_url: cimd_url)
transport = HTTP.new(url: @mcp_url, oauth: provider)
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
# DCR was skipped and the CIMD URL was used as the client_id on
# the escalated authorization request.
assert_not_requested(:post, "#{@auth_base}/register")
authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h
assert_equal(cimd_url, authorization_query["client_id"])
end
def test_initialize_rejects_non_secure_mcp_url_when_oauth_is_set
# A bearer token sent to `http://attacker.example.com/mcp` would leak
# over the wire. When OAuth is on, the transport URL must clear
@ -1080,6 +1390,99 @@ module MCP
# proving the retry guard prevents an infinite re-authorization loop.
assert_requested(:post, "#{@auth_base}/token", times: 1)
end
private
# Stubs PRM, AS metadata, /register, and /token so a full authorization
# flow runs. The /token stub echoes `requested_scope` so callers can
# assert the escalated scope was sent.
# `extra_as_metadata` lets a test advertise additional AS capabilities
# (e.g. `scopes_supported` for the offline_access interaction, or
# `client_id_metadata_document_supported` for the CIMD interaction)
# without duplicating the whole stub.
def stub_step_up_authorization_server(extra_as_metadata: {})
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
resource: "https://srv.example.com/mcp",
authorization_servers: [@auth_base],
),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate({
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
}.merge(extra_as_metadata)),
)
stub_request(:post, "#{@auth_base}/register").to_return(
status: 201,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(client_id: "test-client"),
)
stub_request(:post, "#{@auth_base}/token").to_return do |request|
form = URI.decode_www_form(request.body).to_h
{
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
access_token: "escalated-token",
token_type: "Bearer",
expires_in: 3600,
requested_scope: form["scope"],
grant_type_received: form["grant_type"],
),
}
end
end
def build_step_up_provider(grant_types: ["authorization_code"], client_id_metadata_document_url: nil)
state_holder = {}
captured_authorization_url = nil
provider = Provider.new(
client_metadata: {
client_name: "ruby-sdk-test",
redirect_uris: ["http://localhost:0/callback"],
grant_types: grant_types,
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) {
captured_authorization_url = url
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
},
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
client_id_metadata_document_url: client_id_metadata_document_url,
)
provider.save_tokens("access_token" => "initial-token", "token_type" => "Bearer")
# `captured_authorization_url` is a closure variable that the redirect handler writes at flow time;
# expose its current value via a method on the provider so tests can assert on what was sent to /authorize.
provider.define_singleton_method(:last_authorization_url) { captured_authorization_url }
provider
end
def build_provider
Provider.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) {},
callback_handler: -> { ["code", "state"] },
)
end
end
end
end