ruby-sdk/lib/mcp/client/oauth/flow.rb
Koichi ITO 3093b3ee3f Set OIDC application_type on Dynamic Client Registration per SEP-837
## Motivation and Context

SEP-837 (modelcontextprotocol/modelcontextprotocol#837, merged for the 2026-07-28 spec release)
updates the authorization spec to require MCP clients to specify an appropriate OIDC `application_type`
(`"native"` vs `"web"`) during Dynamic Client Registration, so the authorization server can apply
the matching redirect URI policy and native and web clients do not collide on redirect URI validation rules.

This mirrors the approach taken by the TypeScript SDK (typescript-sdk#2266) and the Python SDK (python-sdk#2784):

- `MCP::Client::OAuth::Discovery.infer_application_type(redirect_uris)` returns `"native"` when every redirect URI
  is a native-app URI (a custom  non-http(s) scheme per RFC 8252 Section 7.1, or an http(s) URI with a loopback host
  per RFC 8252 Section 7.3) and `"web"` otherwise, including for nil, empty, or unparseable input.
  Loopback detection reuses the existing `loopback_host?` helper, so lookalike hosts such as `localhost.example.com`
  or `127.attacker.com` are not treated as native.
- `Flow#ensure_client_registered` now posts the metadata through the new `registration_client_metadata` helper,
  which merges the inferred `application_type` into the DCR request body only when the user did not set
  one explicitly (symbol or string key); an explicit value always wins.

Resolves #375.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/discovery_test.rb` cover the inference: loopback http URIs (IPv4, IPv6, `localhost`),
custom schemes, https URIs, mixed lists, the `localhost.example.com` lookalike, nil/empty input, and unparseable URIs.

New tests in `test/mcp/client/oauth/flow_test.rb` assert on the actual DCR request body via WebMock `assert_requested`:
loopback redirect URI registers `"application_type": "native"`, an HTTPS redirect URI registers `"web"`,
and an explicit `application_type` (symbol- or string-keyed) is never overridden.

## Breaking Changes

None. The DCR request body gains an `application_type` member only when the user has not specified one;
RFC 7591 requires authorization servers to accept and use known metadata fields, and OIDC-registration servers
already default this field to `"web"` when absent.
2026-06-15 00:09:39 +09:00

730 lines
33 KiB
Ruby

# frozen_string_literal: true
require "base64"
require "json"
require "openssl"
require "securerandom"
require "uri"
module MCP
class Client
module OAuth
# 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).
class Flow
class AuthorizationError < StandardError; end
# Raised specifically when the token endpoint rejects a grant with
# `error: "invalid_grant"` (RFC 6749 §5.2). Callers use this to
# distinguish "the stored refresh token is dead, discard it" from
# transient failures (network, 5xx, other RFC 6749 error codes) that
# should leave the refresh token intact.
class InvalidGrantError < AuthorizationError; end
def initialize(provider:, http_client_factory: nil)
@provider = provider
@http_client_factory = http_client_factory || -> { default_http_client }
end
# Runs the full discovery, registration, authorization, and token exchange flow.
# On success, persists tokens via the provider and returns `:authorized`.
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 resource_metadata_url
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
end
prm = fetch_protected_resource_metadata(
server_url: server_url,
resource_metadata_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"])
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
ensure_secure_endpoints!(as_metadata)
if provider_authorization_flow == :client_credentials
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
end
ensure_pkce_supported!(as_metadata)
client_info = ensure_client_registered(as_metadata: as_metadata)
effective_scope = resolve_scope(scope: scope, prm: prm)
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
pkce = PKCE.generate
state = SecureRandom.urlsafe_base64(32)
authorization_url = build_authorization_url(
as_metadata: 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: as_metadata,
client_info: client_info,
code: code,
code_verifier: pkce[:code_verifier],
resource: resource,
)
@provider.save_tokens(tokens)
:authorized
end
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
# and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
client_info = client_credentials_client_info
form = { "grant_type" => "client_credentials" }
effective_scope = resolve_scope(scope: scope, prm: prm)
form["scope"] = effective_scope if effective_scope
form["resource"] = resource if resource
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
@provider.save_tokens(tokens)
:authorized
end
# Reads the pre-registered credentials for the `client_credentials` grant directly from the provider's stored
# `client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code
# flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`).
# The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback
# to dynamic registration.
def client_credentials_client_info
info = @provider.client_information
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
raise AuthorizationError,
"Cannot run the client_credentials grant: the provider has no stored `client_id`."
end
info
end
# 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.
# https://www.rfc-editor.org/rfc/rfc6749#section-6
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
stored_client_info = @provider.client_information
have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
# A CIMD-configured provider stores no `client_information` on purpose
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
unless have_stored_client_info || have_cimd_url
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
end
if resource_metadata_url
ensure_secure_url!(resource_metadata_url, label: "WWW-Authenticate resource_metadata URL")
end
prm = fetch_protected_resource_metadata(
server_url: server_url,
resource_metadata_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"])
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
ensure_secure_endpoints!(as_metadata)
client_info = if have_stored_client_info
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
# do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
stored_client_info
elsif as_metadata["client_id_metadata_document_supported"] == true
{ "client_id" => @provider.client_id_metadata_document_url }
else
raise AuthorizationError,
"Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
"`client_id_metadata_document_supported: true`."
end
new_tokens = exchange_refresh_token(
as_metadata: as_metadata,
client_info: client_info,
refresh_token: refresh_token,
resource: resource,
)
@provider.save_tokens(preserve_refresh_token(new_tokens, refresh_token))
:refreshed
end
private
def read_token(key)
tokens = @provider.tokens
return unless tokens.is_a?(Hash)
value = tokens[key] || tokens[key.to_sym]
value.to_s.empty? ? nil : value
end
# Per RFC 6749 Section 6, the refresh response MAY omit `refresh_token`, in
# which case the previous one stays valid. Preserve it explicitly so
# downstream refresh attempts still work.
def preserve_refresh_token(new_tokens, previous_refresh_token)
return new_tokens if new_tokens["refresh_token"] || new_tokens[:refresh_token]
new_tokens.merge("refresh_token" => previous_refresh_token)
end
def fetch_protected_resource_metadata(server_url:, resource_metadata_url:)
urls = Discovery.protected_resource_metadata_urls(
server_url: server_url,
resource_metadata_url: resource_metadata_url,
)
fetch_metadata_json(urls, label: "protected resource metadata")
end
def fetch_authorization_server_metadata(issuer_url:)
urls = Discovery.authorization_server_metadata_urls(issuer_url)
fetch_metadata_json(urls, label: "authorization server metadata")
end
# Reads `authorization_servers` from a PRM document and returns
# the first entry, raising `AuthorizationError` for any of the malformed
# shapes a non-compliant server could emit (missing field, non-Array
# value, empty Array, non-String first entry). Centralizing this lets
# both the full flow and the refresh flow share the same defensive
# parse instead of each one duplicating a Hash-and-Array check.
def first_authorization_server(prm)
authorization_servers = prm["authorization_servers"]
unless authorization_servers.is_a?(Array)
raise AuthorizationError,
"Protected resource metadata `authorization_servers` is not an array " \
"(got #{authorization_servers.class})."
end
if authorization_servers.empty?
raise AuthorizationError, "Protected resource metadata has no authorization_servers."
end
first = authorization_servers.first
unless first.is_a?(String) && !first.empty?
raise AuthorizationError,
"Protected resource metadata `authorization_servers[0]` is not a non-empty string."
end
first
end
# Walks candidate metadata URLs and returns the parsed JSON body of
# the first 2xx response. Raises `AuthorizationError` for transport
# failures (`Faraday::Error`) and malformed bodies (`JSON::ParserError`)
# so callers do not have to handle raw Faraday/JSON exceptions.
def fetch_metadata_json(urls, label:)
last_error = nil
urls.each do |url|
response = begin
http_get(url)
rescue Faraday::Error => e
last_error = "GET #{url} raised #{e.class}: #{e.message}"
next
end
if response.status >= 200 && response.status < 300
parsed = begin
JSON.parse(response_body_string(response))
rescue JSON::ParserError => e
raise AuthorizationError, "Failed to parse #{label} from #{url}: #{e.message}."
end
# Even valid JSON can be the wrong shape (a top-level array,
# a bare `null`, a string, ...). The discovery callers index by
# name (`prm["authorization_servers"]`, etc.), so anything that
# is not a Hash would raise `TypeError` / `NoMethodError`
# downstream. Surface that as `AuthorizationError` instead so
# callers see a single, documented error type.
unless parsed.is_a?(Hash)
raise AuthorizationError,
"#{label} from #{url} is not a JSON object (got #{parsed.class})."
end
return parsed
end
last_error = "GET #{url} returned #{response.status}"
end
raise AuthorizationError, "Failed to fetch #{label}: #{last_error}."
end
def ensure_pkce_supported!(as_metadata)
methods = as_metadata["code_challenge_methods_supported"]
return if methods.is_a?(Array) && methods.include?("S256")
raise AuthorizationError,
"Authorization server does not advertise S256 PKCE support; refusing to proceed."
end
# Per the MCP authorization spec's Communication Security requirement,
# OAuth endpoints MUST use HTTPS unless the host is a loopback address.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#communication-security
def ensure_secure_url!(url, label:)
return if Discovery.secure_url?(url)
raise AuthorizationError,
"#{label} #{url.inspect} is not over HTTPS; refusing to use it (MCP authorization Communication Security)."
end
def ensure_secure_endpoints!(as_metadata)
["authorization_endpoint", "token_endpoint", "registration_endpoint"].each do |key|
endpoint = as_metadata[key]
ensure_secure_url!(endpoint, label: "Authorization server #{key}") if endpoint
end
end
# Per RFC 8414 Section 3.3, the AS metadata document's `issuer` value MUST be
# identical (literal byte-for-byte equality, no normalization) to
# the issuer URL the client used to discover that document. This guards
# against a CDN/relay returning the metadata of a *different*
# authorization server than the one PRM advertised, and against
# ambiguities like trailing `/`, fragments, or case differences that
# could mask a confused-deputy attempt.
# https://www.rfc-editor.org/rfc/rfc8414#section-3.3
def ensure_issuer_matches!(expected:, returned:)
unless returned
raise AuthorizationError, "Authorization server metadata is missing the `issuer` field."
end
return if expected.to_s == returned.to_s
raise AuthorizationError,
"Authorization server metadata `issuer` does not match the discovery URL " \
"(expected #{expected.inspect}, got #{returned.inspect})."
end
def ensure_client_registered(as_metadata:)
existing = @provider.client_information
return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
# Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
# if the authorization server advertises Client ID Metadata Document support and the provider has
# a CIMD URL configured, use the URL as the OAuth `client_id` and skip Dynamic Client Registration.
#
# The `== true` comparison is intentional: only a JSON `boolean` `true` opts the flow in.
# A string `"false"`, an empty Hash, or any other truthy value MUST NOT be treated as CIMD support,
# otherwise a misconfigured AS could trick the client into using the CIMD `client_id` against
# a server that has not actually adopted it.
#
# The CIMD `client_id` is NOT persisted to storage. The AS may later stop advertising CIMD support
# (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
# keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
# and the current `provider.client_id_metadata_document_url`.
cimd_url = @provider.client_id_metadata_document_url
if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
return { "client_id" => cimd_url }
end
registration_endpoint = as_metadata["registration_endpoint"]
unless registration_endpoint
raise AuthorizationError,
"Authorization server has no registration_endpoint and no pre-registered client information was provided."
end
response = begin
http_post_json(registration_endpoint, registration_client_metadata)
rescue Faraday::Error => e
raise AuthorizationError,
"Dynamic client registration failed: #{e.class}: #{e.message}."
end
if response.status < 200 || response.status >= 300
raise AuthorizationError, "Dynamic client registration failed with status #{response.status}."
end
info = begin
JSON.parse(response_body_string(response))
rescue JSON::ParserError => e
raise AuthorizationError,
"Failed to parse dynamic client registration response: #{e.message}."
end
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
raise AuthorizationError,
"Dynamic client registration response is missing `client_id`."
end
@provider.save_client_information(info)
info
end
# Returns the client metadata to submit on Dynamic Client Registration.
# Per SEP-837, MCP clients MUST specify an appropriate OIDC `application_type`
# so the authorization server can apply the matching redirect URI policy.
# When the user did not set one explicitly, infer `"native"` vs `"web"` from
# the registered `redirect_uris`; an explicit value always wins.
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837
def registration_client_metadata
metadata = @provider.client_metadata
return metadata if metadata[:application_type] || metadata["application_type"]
redirect_uris = metadata[:redirect_uris] || metadata["redirect_uris"]
metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
end
# Reads `key` from a `client_information` hash that may use either string or
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
# `{ client_id:, client_secret: }` and have both work.
def client_info_value(info, key)
info[key] || info[key.to_sym]
end
# Same as `client_info_value` but treats blank strings (`""` or only
# whitespace) as absent. Used for fields where empty values are never
# meaningful (`client_id`, `client_secret`, `token_endpoint_auth_method`)
# and would otherwise let a misbehaving AS or hand-built
# `client_information` short-circuit the "is the client registered?"
# check, or send a literal `client_secret: " "` to the token endpoint.
def client_info_required_value(info, key)
value = client_info_value(info, key)
return if value.nil?
return if value.is_a?(String) && value.strip.empty?
value
end
# Returns the canonical RFC 8707 `resource` URI to send on authorization and
# token requests. When PRM advertises `resource`, that value is
# the authorization server's idea of the resource identifier and is preferred.
# When PRM omits it, the canonicalized MCP server URL is used.
#
# Either way, we validate that PRM's `resource` covers the MCP server URL
# the client is actually talking to (same origin, with PRM's path as a prefix of
# the server URL's path) to prevent a malicious or misconfigured PRM from
# redirecting credentials to a different audience.
def canonical_resource(server_url:, prm_resource:)
server_canonical = safe_canonicalize_url(server_url, label: "MCP server URL")
return server_canonical unless prm_resource
prm_canonical = safe_canonicalize_url(prm_resource, label: "PRM `resource`")
unless Discovery.resource_covers?(prm: prm_canonical, server: server_canonical)
raise AuthorizationError,
"Protected resource metadata `resource` does not match the MCP server URL " \
"(server=#{server_canonical}, prm=#{prm_canonical})."
end
prm_canonical
end
# Wraps `Discovery.canonicalize_url` so that any URI parsing failure
# caused by malformed input from the server (`PRM.resource`, AS metadata
# endpoints, ...) surfaces as `AuthorizationError` instead of leaking
# a raw `URI::InvalidURIError` / `ArgumentError`.
def safe_canonicalize_url(url, label:)
Discovery.canonicalize_url(url)
rescue URI::InvalidURIError, ArgumentError => e
raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}."
end
# Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery
# of the expected value.
# `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7
# (the project's minimum supported version).
# The hand-rolled XOR-sum walks every byte of the equal-length operands, so the running time
# does not leak the position of the first mismatching byte.
def states_match?(returned, expected)
returned = returned.to_s
return false unless returned.bytesize == expected.bytesize
result = 0
returned.bytes.zip(expected.bytes) { |a, b| result |= a ^ b }
result.zero?
end
# Per MCP 2025-11-25 Authorization and the TS/Python SDKs, scope resolution
# prefers the `WWW-Authenticate` challenge first, then `scopes_supported`
# from the Protected Resource Metadata, and falls back to a provider-supplied
# scope only if both are absent. The provider-supplied scope must not pre-empt
# a server-advertised one.
def resolve_scope(scope:, prm:)
return scope if scope && !scope.empty?
supported = prm["scopes_supported"]
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
return @provider.scope if @provider.scope && !@provider.scope.empty?
nil
end
# Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves:
#
# - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing
# `refresh_token` in its registered `grant_types`) MAY request `offline_access`
# when the authorization server advertises it in metadata `scopes_supported`.
# When the server advertises it and the client opted in, add it if absent.
#
# - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`,
# strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge,
# PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT
# request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge,
# or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into
# the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
# own request consistent with the AS's advertisement.
#
# Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
def normalize_offline_access_scope(scope, as_metadata:)
scopes = scope.to_s.split
if server_supports_offline_access?(as_metadata)
scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access")
else
scopes.delete("offline_access")
end
scopes.empty? ? nil : scopes.join(" ")
end
def server_supports_offline_access?(as_metadata)
supported = as_metadata["scopes_supported"]
supported.is_a?(Array) && supported.include?("offline_access")
end
def wants_refresh_token?
metadata = @provider.client_metadata
grant_types = metadata[:grant_types] || metadata["grant_types"]
Array(grant_types).include?("refresh_token")
end
# The OAuth flow the provider drives. Dispatching on the provider's
# declared flow keeps `Flow` from second-guessing intent by parsing
# `client_metadata[:grant_types]` (which is protocol metadata for the
# authorization server, not an SDK control signal). A provider that
# predates this method is treated as the interactive authorization-code
# flow it was the only option for.
def provider_authorization_flow
return :authorization_code unless @provider.respond_to?(:authorization_flow)
@provider.authorization_flow
end
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
authorization_endpoint = as_metadata["authorization_endpoint"]
unless authorization_endpoint
raise AuthorizationError,
"Authorization server metadata is missing `authorization_endpoint`."
end
uri = begin
URI.parse(authorization_endpoint)
rescue URI::InvalidURIError => e
raise AuthorizationError,
"Authorization server metadata `authorization_endpoint` is not a valid URI: #{e.message}."
end
params = URI.decode_www_form(uri.query.to_s)
params << ["response_type", "code"]
params << ["client_id", client_id]
params << ["redirect_uri", @provider.redirect_uri]
params << ["code_challenge", code_challenge]
params << ["code_challenge_method", "S256"]
params << ["state", state]
params << ["scope", scope] if scope
params << ["resource", resource] if resource
uri.query = URI.encode_www_form(params)
uri
end
def exchange_authorization_code(as_metadata:, client_info:, code:, code_verifier:, resource:)
form = {
"grant_type" => "authorization_code",
"code" => code,
"redirect_uri" => @provider.redirect_uri,
"code_verifier" => code_verifier,
}
form["resource"] = resource if resource
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
end
def exchange_refresh_token(as_metadata:, client_info:, refresh_token:, resource:)
form = {
"grant_type" => "refresh_token",
"refresh_token" => refresh_token,
}
form["resource"] = resource if resource
post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
end
# Submits a form-encoded request to the token endpoint, applying
# the client authentication method advertised in `client_information` and
# adding `client_id` (and `client_secret` when not using HTTP Basic).
def post_to_token_endpoint(as_metadata:, client_info:, form:)
client_id = client_info_required_value(client_info, "client_id")
unless client_id
raise AuthorizationError,
"Cannot post to token endpoint: client_information is missing `client_id`."
end
client_secret = client_info_required_value(client_info, "client_secret")
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
form = form.merge("client_id" => client_id)
headers = {}
if client_secret
case token_endpoint_auth_method
when "client_secret_post"
form["client_secret"] = client_secret
when "none"
# Public client; no credential.
else
# RFC 6749 §2.3.1 recommends Basic for confidential clients and
# both Python and TypeScript SDKs default here when
# the authentication method is not explicitly stored.
headers["Authorization"] = "Basic " + basic_auth_credentials(client_id, client_secret)
end
end
token_endpoint = as_metadata["token_endpoint"]
unless token_endpoint
raise AuthorizationError,
"Authorization server metadata is missing `token_endpoint`."
end
response = begin
http_post_form(token_endpoint, form, headers: headers)
rescue Faraday::Error => e
raise AuthorizationError,
"Token request to #{token_endpoint} failed: #{e.class}: #{e.message}."
end
if response.status < 200 || response.status >= 300
if token_endpoint_error_code(response) == "invalid_grant"
raise InvalidGrantError, "Token endpoint rejected the grant: invalid_grant."
end
raise AuthorizationError, "Token endpoint returned status #{response.status}."
end
parsed = begin
JSON.parse(response_body_string(response))
rescue JSON::ParserError => e
raise AuthorizationError, "Failed to parse token endpoint response: #{e.message}."
end
# Token responses MUST be a JSON object per RFC 6749 §5.1. Anything
# else (`null`, `[]`, a bare string) would otherwise be persisted
# as `provider.tokens` and raise raw `NoMethodError` / `TypeError`
# the next time `provider.access_token` is read.
unless parsed.is_a?(Hash)
raise AuthorizationError,
"Token endpoint response is not a JSON object (got #{parsed.class})."
end
parsed
end
# Extracts the `error` code from an RFC 6749 §5.2 error response body
# when one is parseable. Returns nil on any parse failure or when
# the body is not JSON.
def token_endpoint_error_code(response)
body = response_body_string(response).to_s
return if body.empty?
parsed = JSON.parse(body)
parsed["error"] if parsed.is_a?(Hash)
rescue JSON::ParserError
nil
end
# Per RFC 6749 Section 2.3.1, the `client_id` and `client_secret` MUST be
# `application/x-www-form-urlencoded` encoded before they are joined with
# `:` and base64-encoded for the `Authorization: Basic` header. This is
# what prevents credentials containing `:` or other special characters
# from being mis-parsed by the authorization server.
# https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1
def basic_auth_credentials(client_id, client_secret)
encoded_id = URI.encode_www_form_component(client_id)
encoded_secret = URI.encode_www_form_component(client_secret)
Base64.strict_encode64("#{encoded_id}:#{encoded_secret}")
end
def http_get(url)
http_client.get(url)
end
def http_post_json(url, body)
http_client.post(url) do |req|
req.headers["Content-Type"] = "application/json"
req.headers["Accept"] = "application/json"
req.body = JSON.generate(body)
end
end
def http_post_form(url, form, headers: {})
http_client.post(url) do |req|
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
req.headers["Accept"] = "application/json"
headers.each { |key, value| req.headers[key] = value }
req.body = URI.encode_www_form(form)
end
end
def http_client
@http_client ||= @http_client_factory.call
end
def default_http_client
require "faraday"
Faraday.new do |faraday|
faraday.headers["Accept"] = "application/json"
end
end
def response_body_string(response)
body = response.body
body.is_a?(String) ? body : body.to_s
end
end
end
end
end