diff --git a/README.md b/README.md index bdf0bd7d..dfa9c142 100644 --- a/README.md +++ b/README.md @@ -2014,6 +2014,29 @@ provider = MCP::Client::OAuth::Provider.new( ) ``` +##### Client Credentials Grant + +For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`. +The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at +the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token. + +```ruby +provider = MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: "my-service", + client_secret: ENV.fetch("MCP_CLIENT_SECRET"), + # token_endpoint_auth_method: "client_secret_basic" (default) or "client_secret_post" + # scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes) +) + +transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider) +``` + +Keyword arguments: + +- `client_id`, `client_secret`: Required. The grant is for confidential clients, so a credential is mandatory. +- `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`. +- `scope`, `storage`: Optional, same meaning as on `Provider`. + ##### Communication Security When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, diff --git a/conformance/client.rb b/conformance/client.rb index ecfd0799..2f495753 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -51,6 +51,30 @@ def conformance_context {} end +# Saves the pre-registered `client_id` / `client_secret` the harness injects +# via context (used by pre-registration and client_credentials scenarios). +def storage_for(context) + storage = MCP::Client::OAuth::InMemoryStorage.new + if context["client_id"] + storage.save_client_information( + "client_id" => context["client_id"], + "client_secret" => context["client_secret"], + "token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic", + ) + end + storage +end + +# Builds a `client_credentials`-only provider (machine-to-machine, no redirect). +# The pre-registered credentials are injected by the harness via context. +def build_client_credentials_provider(context) + MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: context["client_id"], + client_secret: context["client_secret"], + token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic", + ) +end + # Builds an OAuth provider that drives the authorization code + PKCE + DCR flow # non-interactively against the conformance test's auth server. The conformance # `/authorize` endpoint redirects synchronously to `redirect_uri` with @@ -74,15 +98,6 @@ def build_oauth_provider(context, scenario:) [query["code"], query["state"]] end - storage = MCP::Client::OAuth::InMemoryStorage.new - if context["client_id"] - storage.save_client_information( - "client_id" => context["client_id"], - "client_secret" => context["client_secret"], - "token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic", - ) - end - MCP::Client::OAuth::Provider.new( client_metadata: { client_name: "ruby-sdk-conformance-client", @@ -94,12 +109,20 @@ def build_oauth_provider(context, scenario:) redirect_uri: redirect_uri, redirect_handler: redirect_handler, callback_handler: callback_handler, - storage: storage, + storage: storage_for(context), client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil), ) end -oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context, scenario: scenario) : nil +def build_provider_for(scenario, context) + if scenario.start_with?("auth/client-credentials") + build_client_credentials_provider(context) + else + build_oauth_provider(context, scenario: scenario) + end +end + +oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth) client = MCP::Client.new(transport: transport) client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION }) diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index f19cd166..35b36a99 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -9,5 +9,4 @@ client: - auth/2025-03-26-oauth-metadata-backcompat - auth/2025-03-26-oauth-endpoint-fallback - auth/client-credentials-jwt - - auth/client-credentials-basic - auth/cross-app-access-complete-flow diff --git a/lib/mcp/client/oauth.rb b/lib/mcp/client/oauth.rb index 5115118e..fc33c175 100644 --- a/lib/mcp/client/oauth.rb +++ b/lib/mcp/client/oauth.rb @@ -4,13 +4,15 @@ require_relative "oauth/flow" require_relative "oauth/in_memory_storage" require_relative "oauth/pkce" +require_relative "oauth/storage_backed_provider" require_relative "oauth/provider" +require_relative "oauth/client_credentials_provider" module MCP class Client # OAuth client support for the MCP Authorization spec (PRM discovery, # Authorization Server metadata discovery, Dynamic Client Registration, - # OAuth 2.1 Authorization Code + PKCE). + # OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant). # https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization module OAuth end diff --git a/lib/mcp/client/oauth/client_credentials_provider.rb b/lib/mcp/client/oauth/client_credentials_provider.rb new file mode 100644 index 00000000..fa01d5c7 --- /dev/null +++ b/lib/mcp/client/oauth/client_credentials_provider.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # OAuth client configuration for the OAuth 2.1 `client_credentials` grant + # (machine-to-machine, no user and no browser redirect). Handed to + # `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`. + # The interactive Authorization Code flow lives in `Provider`; + # this class exists so a credentials-only client never has to supply + # the redirect arguments that grant has no use for, mirroring the dedicated + # `ClientCredentialsProvider` in the TypeScript SDK and + # `ClientCredentialsOAuthProvider` in the Python SDK. + # + # Required keyword arguments: + # + # - `client_id` - String identifying the pre-registered confidential client. + # - `client_secret` - String shared secret. The `client_credentials` grant + # is for confidential clients, so a credential is mandatory. + # + # Optional keyword arguments: + # + # - `token_endpoint_auth_method` - `"client_secret_basic"` (default) or + # `"client_secret_post"`. `"none"` is rejected: an unauthenticated + # `client_credentials` request is meaningless. + # - `scope` - String of space-separated scopes to request when the server's + # `WWW-Authenticate` and the Protected Resource Metadata do not specify one. + # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, + # `client_information`, and `save_client_information(info)`. Defaults to + # an `InMemoryStorage`. The `client_id` / `client_secret` are written + # into it so the token exchange reads them through the same path as + # a pre-registered authorization-code client. + class ClientCredentialsProvider + include StorageBackedProvider + + # Raised when the credentials required for the `client_credentials` grant are + # missing or the requested client authentication method cannot carry them. + class InvalidCredentialsError < ArgumentError; end + + SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post"].freeze + + attr_reader :scope, :storage + + def initialize( + client_id:, + client_secret:, + token_endpoint_auth_method: "client_secret_basic", + scope: nil, + storage: nil + ) + if blank?(client_id) + raise InvalidCredentialsError, "client_id is required for the client_credentials grant." + end + + unless SUPPORTED_AUTH_METHODS.include?(token_endpoint_auth_method) + raise InvalidCredentialsError, + "token_endpoint_auth_method must be one of #{SUPPORTED_AUTH_METHODS.inspect} for the " \ + "client_credentials grant (got #{token_endpoint_auth_method.inspect}); an unauthenticated " \ + "client_credentials request is not allowed." + end + + if blank?(client_secret) + raise InvalidCredentialsError, + "client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}." + end + + @scope = scope + @storage = storage || InMemoryStorage.new + @storage.save_client_information( + "client_id" => client_id, + "client_secret" => client_secret, + "token_endpoint_auth_method" => token_endpoint_auth_method, + ) + end + + # See `Provider#authorization_flow`. + def authorization_flow + :client_credentials + end + + private + + def blank?(value) + value.nil? || (value.is_a?(String) && value.strip.empty?) + end + end + end + end +end diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index aed54642..ed76ee59 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -54,6 +54,11 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil) 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) @@ -92,14 +97,46 @@ def run!(server_url:, resource_metadata_url: nil, scope: nil) :authorized 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. + # 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. + # 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") @@ -110,8 +147,7 @@ def refresh!(server_url:, resource_metadata_url: nil) # 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. + # 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 @@ -484,6 +520,18 @@ def wants_refresh_token? 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 diff --git a/lib/mcp/client/oauth/provider.rb b/lib/mcp/client/oauth/provider.rb index 139c07f3..ce10dad5 100644 --- a/lib/mcp/client/oauth/provider.rb +++ b/lib/mcp/client/oauth/provider.rb @@ -3,9 +3,11 @@ module MCP class Client module OAuth - # Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via - # the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK - # and httpx.Auth-based provider in the Python SDK. + # Pluggable OAuth client configuration for the OAuth 2.1 Authorization Code + PKCE flow, + # handed to `MCP::Client::HTTP` via the `oauth:` keyword. + # Inspired by the OAuthClientProvider in the TypeScript SDK and the httpx.Auth-based provider + # in the Python SDK. For the non-interactive machine-to-machine `client_credentials` grant, + # use `ClientCredentialsProvider` instead. # # Required keyword arguments: # - `client_metadata` - Hash sent to the authorization server's Dynamic Client @@ -36,6 +38,8 @@ module OAuth # DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set # to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`. class Provider + include StorageBackedProvider + # Raised when `Provider#initialize` is called with a `redirect_uri` that # is neither HTTPS nor a loopback `http://` URL, per the MCP # authorization spec's Communication Security requirement. @@ -102,28 +106,11 @@ def initialize( @client_id_metadata_document_url = client_id_metadata_document_url end - def access_token - tokens&.dig("access_token") || tokens&.dig(:access_token) - end - - def tokens - @storage.tokens - end - - def save_tokens(tokens) - @storage.save_tokens(tokens) - end - - def client_information - @storage.client_information - end - - def save_client_information(info) - @storage.save_client_information(info) - end - - def clear_tokens! - @storage.save_tokens(nil) + # Identifies the OAuth flow this provider drives. + # `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`, + # which is protocol metadata for the authorization server, not an SDK control signal. + def authorization_flow + :authorization_code end end end diff --git a/lib/mcp/client/oauth/storage_backed_provider.rb b/lib/mcp/client/oauth/storage_backed_provider.rb new file mode 100644 index 00000000..499db6b2 --- /dev/null +++ b/lib/mcp/client/oauth/storage_backed_provider.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module MCP + class Client + module OAuth + # Shared token/credential persistence for the OAuth provider classes + # (`Provider` for the authorization-code flow and `ClientCredentialsProvider` + # for the client_credentials flow). The two grants differ in how they authenticate, + # but both read and write the same two pieces of state through a `storage` object: + # the token response and the client information. This module supplies that delegation + # so the `Flow` orchestrator can treat any provider uniformly. + # + # Including classes must set `@storage` to an object responding to `tokens`, + # `save_tokens(tokens)`, `client_information`, and `save_client_information(info)` + # (see `InMemoryStorage`). + module StorageBackedProvider + def access_token + tokens&.dig("access_token") || tokens&.dig(:access_token) + end + + def tokens + @storage.tokens + end + + def save_tokens(tokens) + @storage.save_tokens(tokens) + end + + def client_information + @storage.client_information + end + + def save_client_information(info) + @storage.save_client_information(info) + end + + def clear_tokens! + @storage.save_tokens(nil) + end + end + end + end +end diff --git a/test/mcp/client/oauth/client_credentials_provider_test.rb b/test/mcp/client/oauth/client_credentials_provider_test.rb new file mode 100644 index 00000000..c62395f5 --- /dev/null +++ b/test/mcp/client/oauth/client_credentials_provider_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class ClientCredentialsProviderTest < Minitest::Test + def test_initialize_stores_credentials_as_client_information + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") + + info = provider.client_information + assert_equal("cc-client", info["client_id"]) + assert_equal("cc-secret", info["client_secret"]) + assert_equal("client_secret_basic", info["token_endpoint_auth_method"]) + end + + def test_authorization_flow_is_client_credentials + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") + + assert_equal(:client_credentials, provider.authorization_flow) + end + + def test_initialize_accepts_client_secret_post + provider = ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: "client_secret_post", + ) + + assert_equal("client_secret_post", provider.client_information["token_endpoint_auth_method"]) + end + + def test_initialize_rejects_missing_client_id + ["", " ", nil].each do |value| + assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do + ClientCredentialsProvider.new(client_id: value, client_secret: "cc-secret") + end + end + end + + def test_initialize_rejects_missing_client_secret + # The client_credentials grant is for confidential clients, so a credential is mandatory. + ["", " ", nil].each do |value| + assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do + ClientCredentialsProvider.new(client_id: "cc-client", client_secret: value) + end + end + end + + def test_initialize_rejects_none_auth_method + # An unauthenticated client_credentials request is meaningless. + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: "none", + ) + end + end + + def test_token_helpers_delegate_to_storage + provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret") + provider.save_tokens("access_token" => "cc-token") + + assert_equal("cc-token", provider.access_token) + provider.clear_tokens! + assert_nil(provider.tokens) + end + end + end + end +end diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index 8c0bcc1d..d36c5e93 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -68,6 +68,14 @@ def teardown WebMock.reset! end + def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic") + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: token_endpoint_auth_method, + ) + end + # Runs the full authorization flow and returns the `scope` query parameter # sent on the authorization request. The caller stubs the AS metadata; # this helper supplies a provider whose `grant_types` and optional pre-set @@ -96,6 +104,102 @@ def capture_authorization_scope(grant_types:, provider_scope: nil) captured_scope end + def test_run_uses_client_credentials_grant_for_client_credentials_provider + provider = client_credentials_provider + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + # No authorization-code machinery: /authorize is never contacted + # (no stub exists for it, so a request would raise), and DCR is not + # run because credentials are pre-registered. + assert_not_requested(:post, "#{@auth_base}/register") + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + expected_basic = "Basic " + Base64.strict_encode64("cc-client:cc-secret") + form["grant_type"] == "client_credentials" && + !form.key?("code") && + !form.key?("code_verifier") && + req.headers["Authorization"] == expected_basic + end + end + + def test_run_client_credentials_with_client_secret_post_sends_credentials_in_body + # `client_secret_post` puts the credentials in the form body rather + # than an HTTP Basic header (RFC 6749 Section 2.3.1). + provider = client_credentials_provider(token_endpoint_auth_method: "client_secret_post") + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + form["grant_type"] == "client_credentials" && + form["client_id"] == "cc-client" && + form["client_secret"] == "cc-secret" && + req.headers["Authorization"].nil? + end + end + + def test_run_client_credentials_raises_clean_error_when_client_information_missing + # The constructor always stores credentials, but if a custom storage loses them + # the grant must fail with a domain error rather than a `NoMethodError` from + # the authorization-code registration helper. + provider = client_credentials_provider + provider.storage.save_client_information(nil) + + error = assert_raises(Flow::AuthorizationError) do + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + end + assert_match(/client_credentials grant/, error.message) + assert_not_requested(:post, "#{@auth_base}/token") + end + + def test_run_client_credentials_requests_scope_from_prm_scopes_supported + 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], + scopes_supported: ["mcp:read", "mcp:write"], + ), + ) + + provider = client_credentials_provider + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["scope"] == "mcp:read mcp:write" + end + end + + def test_run_uses_authorization_code_grant_for_default_provider + # A standard `Provider` declares `authorization_flow == :authorization_code`, + # so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list. + state_holder = {} + provider = Provider.new( + client_metadata: { + redirect_uris: ["http://localhost:0/callback"], + grant_types: ["authorization_code", "client_credentials"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + redirect_uri: "http://localhost:0/callback", + redirect_handler: ->(url) { + state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") + }, + callback_handler: -> { ["test-auth-code", state_holder[:state]] }, + ) + + Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_requested(:post, "#{@auth_base}/token") do |req| + URI.decode_www_form(req.body).to_h["grant_type"] == "authorization_code" + end + end + def test_run_completes_full_authorization_flow captured_authorization_url = nil state_value = nil diff --git a/test/mcp/client/oauth/provider_test.rb b/test/mcp/client/oauth/provider_test.rb index 2b17ed5d..3db9427b 100644 --- a/test/mcp/client/oauth/provider_test.rb +++ b/test/mcp/client/oauth/provider_test.rb @@ -181,6 +181,12 @@ def test_initialize_rejects_client_id_metadata_document_url_with_dot_segments end end end + + def test_authorization_flow_is_authorization_code + provider = Provider.new(**args_for("https://app.example.com/callback")) + + assert_equal(:authorization_code, provider.authorization_flow) + end end end end