From 7417d62505aa9a56af6cb870a95baadacedf68dd Mon Sep 17 00:00:00 2001 From: Mazine Mrini Date: Thu, 29 May 2025 21:24:42 -0400 Subject: [PATCH 1/6] Adds basic models and errors to the auth module --- lib/mcp.rb | 2 + lib/mcp/auth/errors.rb | 11 ++ lib/mcp/auth/models.rb | 300 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 lib/mcp/auth/errors.rb create mode 100644 lib/mcp/auth/models.rb diff --git a/lib/mcp.rb b/lib/mcp.rb index 7eb8870..561a647 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -18,6 +18,8 @@ require_relative "mcp/version" require_relative "mcp/configuration" require_relative "mcp/methods" +require_relative "mcp/auth/errors" +require_relative "mcp/auth/models" module MCP class << self diff --git a/lib/mcp/auth/errors.rb b/lib/mcp/auth/errors.rb new file mode 100644 index 0000000..d9aafb1 --- /dev/null +++ b/lib/mcp/auth/errors.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module MCP + module Auth + module Errors + class InvalidScopeError < StandardError; end + + class InvalidRedirectUriError < StandardError; end + end + end +end diff --git a/lib/mcp/auth/models.rb b/lib/mcp/auth/models.rb new file mode 100644 index 0000000..5cec871 --- /dev/null +++ b/lib/mcp/auth/models.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +require_relative "errors" + +module MCP + module Auth + module Models + # See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 + class OAuthToken + attr_accessor :access_token, + :token_type, + :expires_in, + :scope, + :refresh_token + + def initialize( + access_token:, + token_type: "bearer", + expires_in: nil, + scope: nil, + refresh_token: nil + ) + raise ArgumentError, "token_type must be 'bearer'" unless token_type == "bearer" + + @access_token = access_token + @token_type = token_type + @expires_in = expires_in + @scope = scope + @refresh_token = refresh_token + end + end + + # Represents OAuth 2.0 Dynamic Client Registration metadata as defined in RFC 7591. + # See https://datatracker.ietf.org/doc/html/rfc7591#section-2 + class OAuthClientMetadata + attr_accessor :redirect_uris, + :token_endpoint_auth_method, + :grant_types, + :response_types, + :scope, + # unused, keeping for future use + :client_name, + :client_uri, + :logo_uri, + :contacts, + :tos_uri, + :policy_uri, + :jwks_uri, + :jwks, + :software_id, + :software_version + + # Supported values for token_endpoint_auth_method + VALID_TOKEN_ENDPOINT_AUTH_METHODS = ["none", "client_secret_post"].freeze + # Supported grant types + VALID_GRANT_TYPES = ["authorization_code", "refresh_token"].freeze + # Supported response types + VALID_RESPONSE_TYPES = ["code"].freeze + + DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_post" + DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"].freeze + DEFAULT_RESPONSE_TYPES = ["code"].freeze + + def initialize( + redirect_uris:, + token_endpoint_auth_method: DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD, + grant_types: DEFAULT_GRANT_TYPES.dup, + response_types: DEFAULT_RESPONSE_TYPES.dup, + scope: nil, + client_name: nil, + client_uri: nil, + logo_uri: nil, + contacts: nil, + tos_uri: nil, + policy_uri: nil, + jwks_uri: nil, + jwks: nil, + software_id: nil, + software_version: nil + ) + raise ArgumentError, "redirect_uris must be a non-empty array" if !redirect_uris.is_a?(Array) || redirect_uris.empty? + + @redirect_uris = redirect_uris + + unless VALID_TOKEN_ENDPOINT_AUTH_METHODS.include?(token_endpoint_auth_method) + raise ArgumentError, "Invalid token_endpoint_auth_method: #{token_endpoint_auth_method}. Valid methods are: #{VALID_TOKEN_ENDPOINT_AUTH_METHODS.join(", ")}" + end + + @token_endpoint_auth_method = token_endpoint_auth_method + + grant_types.each do |gt| + unless VALID_GRANT_TYPES.include?(gt) + raise ArgumentError, "Invalid grant_type: #{gt}. Valid grant types are: #{VALID_GRANT_TYPES.join(", ")}" + end + end + @grant_types = grant_types + + response_types.each do |rt| + unless VALID_RESPONSE_TYPES.include?(rt) + raise ArgumentError, "Invalid response_type: #{rt}. Valid response types are: #{VALID_RESPONSE_TYPES.join(", ")}" + end + end + @response_types = response_types + + @scope = scope + @client_name = client_name + @client_uri = client_uri + @logo_uri = logo_uri + @contacts = contacts + @tos_uri = tos_uri + @policy_uri = policy_uri + @jwks_uri = jwks_uri + @jwks = jwks + @software_id = software_id + @software_version = software_version + end + + def validate_scope(requested_scope) + return if requested_scope.nil? || requested_scope.empty? + + requested_scopes = requested_scope.split(" ") + allowed_scopes = @scope.nil? ? [] : @scope.split(" ") + + requested_scopes.each do |s| + unless allowed_scopes.include?(s) + raise Errors::InvalidScopeError, "Client was not registered with scope '#{s}'" + end + end + + requested_scopes + end + + def validate_redirect_uri(redirect_uri) + if redirect_uri + unless @redirect_uris.include?(redirect_uri) + raise Errors::InvalidRedirectUriError, "Redirect URI '#{redirect_uri}' not registered for client" + end + + redirect_uri_str + elsif @redirect_uris.one? + @redirect_uris.first + else + raise Errors::InvalidRedirectUriError, "redirect_uri must be specified when client has multiple registered URIs" + end + end + end + + # Represents full OAuth 2.0 Dynamic Client Registration information (metadata + client details). + # RFC 7591 + class OAuthClientInformationFull < OAuthClientMetadata + attr_accessor :client_id, + :client_secret, + :client_id_issued_at, + :client_secret_expires_at + + def initialize( + client_id:, + client_secret: nil, + client_id_issued_at: nil, + client_secret_expires_at: nil, + **metadata_args + ) + super(**metadata_args) + @client_id = client_id + @client_secret = client_secret + @client_id_issued_at = client_id_issued_at + @client_secret_expires_at = client_secret_expires_at + end + end + + # Represents OAuth 2.0 Authorization Server Metadata as defined in RFC 8414. + # See https://datatracker.ietf.org/doc/html/rfc8414#section-2 + class OAuthMetadata + attr_accessor :issuer, + :authorization_endpoint, + :token_endpoint, + :registration_endpoint, + :scopes_supported, + :response_types_supported, + :response_modes_supported, + :grant_types_supported, + :token_endpoint_auth_methods_supported, + :token_endpoint_auth_signing_alg_values_supported, + :service_documentation, + :ui_locales_supported, + :op_policy_uri, + :op_tos_uri, + :revocation_endpoint, + :revocation_endpoint_auth_methods_supported, + :revocation_endpoint_auth_signing_alg_values_supported, + :introspection_endpoint, + :introspection_endpoint_auth_methods_supported, + :introspection_endpoint_auth_signing_alg_values_supported, + :code_challenge_methods_supported + + # Default and supported values based on Python model + DEFAULT_RESPONSE_TYPES_SUPPORTED = ["code"].freeze + + VALID_RESPONSE_TYPES_SUPPORTED = ["code"].freeze + VALID_RESPONSE_MODES_SUPPORTED = ["query", "fragment"].freeze + VALID_GRANT_TYPES_SUPPORTED = ["authorization_code", "refresh_token"].freeze + VALID_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED = ["none", "client_secret_post"].freeze + VALID_REVOCATION_ENDPOINT_AUTH_METHODS_SUPPORTED = ["client_secret_post"].freeze + VALID_INTROSPECTION_ENDPOINT_AUTH_METHODS_SUPPORTED = ["client_secret_post"].freeze + VALID_CODE_CHALLENGE_METHODS_SUPPORTED = ["S256"].freeze + + def initialize( + issuer:, + authorization_endpoint:, + token_endpoint:, + registration_endpoint: nil, + scopes_supported: nil, + response_types_supported: DEFAULT_RESPONSE_TYPES_SUPPORTED.dup, + response_modes_supported: nil, + grant_types_supported: nil, + token_endpoint_auth_methods_supported: nil, + token_endpoint_auth_signing_alg_values_supported: nil, + service_documentation: nil, + ui_locales_supported: nil, + op_policy_uri: nil, + op_tos_uri: nil, + revocation_endpoint: nil, + revocation_endpoint_auth_methods_supported: nil, + revocation_endpoint_auth_signing_alg_values_supported: nil, + introspection_endpoint: nil, + introspection_endpoint_auth_methods_supported: nil, + introspection_endpoint_auth_signing_alg_values_supported: nil, + code_challenge_methods_supported: nil + ) + @issuer = issuer + @authorization_endpoint = authorization_endpoint + @token_endpoint = token_endpoint + @registration_endpoint = registration_endpoint + @scopes_supported = scopes_supported # list[str] | None + + (response_types_supported || []).each do |rt| + unless VALID_RESPONSE_TYPES_SUPPORTED.include?(rt) + raise ArgumentError, "Invalid response_type_supported: #{rt}. Valid types are: #{VALID_RESPONSE_TYPES_SUPPORTED.join(", ")}" + end + end + @response_types_supported = response_types_supported + + (response_modes_supported || []).each do |rm| + unless VALID_RESPONSE_MODES_SUPPORTED.include?(rm) + raise ArgumentError, "Invalid response_mode_supported: #{rm}. Valid modes are: #{VALID_RESPONSE_MODES_SUPPORTED.join(", ")}" + end + end + @response_modes_supported = response_modes_supported + + (grant_types_supported || []).each do |gt| + unless VALID_GRANT_TYPES_SUPPORTED.include?(gt) + raise ArgumentError, "Invalid grant_type_supported: #{gt}. Valid types are: #{VALID_GRANT_TYPES_SUPPORTED.join(", ")}" + end + end + @grant_types_supported = grant_types_supported + + (token_endpoint_auth_methods_supported || []).each do |team| + unless VALID_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED.include?(team) + raise ArgumentError, "Invalid token_endpoint_auth_method_supported: #{team}. Valid methods are: #{VALID_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED.join(", ")}" + end + end + @token_endpoint_auth_methods_supported = token_endpoint_auth_methods_supported + + @token_endpoint_auth_signing_alg_values_supported = token_endpoint_auth_signing_alg_values_supported # Always nil in Python + @service_documentation = service_documentation + @ui_locales_supported = ui_locales_supported + @op_policy_uri = op_policy_uri + @op_tos_uri = op_tos_uri + @revocation_endpoint = revocation_endpoint + + (revocation_endpoint_auth_methods_supported || []).each do |ream| + unless VALID_REVOCATION_ENDPOINT_AUTH_METHODS_SUPPORTED.include?(ream) + raise ArgumentError, "Invalid revocation_endpoint_auth_method_supported: #{ream}. Valid methods are: #{VALID_REVOCATION_ENDPOINT_AUTH_METHODS_SUPPORTED.join(", ")}" + end + end + @revocation_endpoint_auth_methods_supported = revocation_endpoint_auth_methods_supported + + @revocation_endpoint_auth_signing_alg_values_supported = revocation_endpoint_auth_signing_alg_values_supported # Always nil in Python + @introspection_endpoint = introspection_endpoint + + (introspection_endpoint_auth_methods_supported || []).each do |ieam| + unless VALID_INTROSPECTION_AUTH_METHODS_SUPPORTED.include?(ieam) # Using VALID_INTROSPECTION_AUTH_METHODS + raise ArgumentError, "Invalid introspection_endpoint_auth_method_supported: #{ieam}. Valid methods are: #{VALID_INTROSPECTION_AUTH_METHODS.join(", ")}" + end + end + @introspection_endpoint_auth_methods_supported = introspection_endpoint_auth_methods_supported + + @introspection_endpoint_auth_signing_alg_values_supported = introspection_endpoint_auth_signing_alg_values_supported # Always nil in Python + + (code_challenge_methods_supported || []).each do |ccm| + unless VALID_CODE_CHALLENGE_METHODS_SUPPORTED.include?(ccm) + raise ArgumentError, "Invalid code_challenge_method_supported: #{ccm}. Valid methods are: #{VALID_CODE_CHALLENGE_METHODS_SUPPORTED.join(", ")}" + end + end + @code_challenge_methods_supported = code_challenge_methods_supported + end + end + end + end +end From 1a5c384055478928b6fd7added6b67e55e71305f Mon Sep 17 00:00:00 2001 From: Mazine Mrini Date: Thu, 29 May 2025 23:57:54 -0400 Subject: [PATCH 2/6] Adds metadata handler and other PORO classes --- lib/mcp.rb | 5 + lib/mcp/auth/errors.rb | 45 ++++ lib/mcp/auth/models.rb | 32 ++- .../auth/server/handlers/metadata_handler.rb | 28 +++ lib/mcp/auth/server/provider.rb | 214 ++++++++++++++++++ lib/mcp/auth/server/settings.rb | 58 +++++ lib/mcp/auth/server/uri_helper.rb | 31 +++ lib/mcp/serialization_utils.rb | 13 ++ 8 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 lib/mcp/auth/server/handlers/metadata_handler.rb create mode 100644 lib/mcp/auth/server/provider.rb create mode 100644 lib/mcp/auth/server/settings.rb create mode 100644 lib/mcp/auth/server/uri_helper.rb create mode 100644 lib/mcp/serialization_utils.rb diff --git a/lib/mcp.rb b/lib/mcp.rb index 561a647..b4ee077 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -20,6 +20,11 @@ require_relative "mcp/methods" require_relative "mcp/auth/errors" require_relative "mcp/auth/models" +require_relative "mcp/auth/server/provider" +require_relative "mcp/auth/server/settings" +require_relative "mcp/auth/server/uri_helper" +require_relative "mcp/auth/server/handlers/metadata_handler" +require_relative "mcp/serialization_utils" module MCP class << self diff --git a/lib/mcp/auth/errors.rb b/lib/mcp/auth/errors.rb index d9aafb1..29139cd 100644 --- a/lib/mcp/auth/errors.rb +++ b/lib/mcp/auth/errors.rb @@ -6,6 +6,51 @@ module Errors class InvalidScopeError < StandardError; end class InvalidRedirectUriError < StandardError; end + + class RegistrationError < StandardError + INVALID_REDIRECT_URI = "invalid_redirect_uri" + INVALID_CLIENT_METADATA = "invalid_client_metadata" + INVALID_SOFTWARE_STATEMENT = "invalid_software_statement" + UNAPPROVED_SOFTARE_STATEMENT = "unapproved_software_statement" + + attr_reader :error_code + + def initialize(error_code:, message: nil) + super(message) + @error_code = error_code + end + end + + class AuthorizationError < StandardError + INVALID_REQUEST = "invalid_request" + UNAUTHORIZED_CLIENT = "unauthorized_client" + ACCESS_DENIED = "access_denied" + UNSUPPORTED_RESPONSE_TYPE = "unsupported_response_type" + INVALID_SCOPE = "invalid_scope" + SERVER_ERROR = "server_error" + TEMPORARILY_UNAVAILABLE = "temporarily_unavailable" + + attr_reader :error_code + + def initialize(error_code:, message: nil) + super(message) + @error_code = error_code + end + end + + class TokenError < StandardError + INVALID_REQUEST = "invalid_request" + INVALID_CLIENT = "invalid_client" + INVALID_GRANT = "invalid_grant" + UNAUTHORIZED_CLIENT = "unauthorized_client" + UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type" + INVALID_SCOPE = "invalid_scope" + + def initialize(error_code:, message: nil) + super(message) + @error_code = error_code + end + end end end end diff --git a/lib/mcp/auth/models.rb b/lib/mcp/auth/models.rb index 5cec871..a38c3c2 100644 --- a/lib/mcp/auth/models.rb +++ b/lib/mcp/auth/models.rb @@ -206,8 +206,8 @@ class OAuthMetadata def initialize( issuer:, - authorization_endpoint:, - token_endpoint:, + authorization_endpoint: nil, + token_endpoint: nil, registration_endpoint: nil, scopes_supported: nil, response_types_supported: DEFAULT_RESPONSE_TYPES_SUPPORTED.dup, @@ -231,7 +231,7 @@ def initialize( @authorization_endpoint = authorization_endpoint @token_endpoint = token_endpoint @registration_endpoint = registration_endpoint - @scopes_supported = scopes_supported # list[str] | None + @scopes_supported = scopes_supported (response_types_supported || []).each do |rt| unless VALID_RESPONSE_TYPES_SUPPORTED.include?(rt) @@ -294,6 +294,32 @@ def initialize( end @code_challenge_methods_supported = code_challenge_methods_supported end + + class << self + DEFAULT_AUTHORIZE_PATH = "/authorize" + DEFAULT_REGISTRATION_PATH = "/register" + DEFAULT_TOKEN_PATH = "/token" + + def from_settings(auth_settings, **kwargs) + metadata = OAuthMetadata.new( + issuer: auth_settings.issuer_url, + authorization_endpoint: auth_settings.issuer_url + DEFAULT_AUTHORIZE_PATH, + token_endpoint: auth_settings.issuer_url + DEFAULT_TOKEN_PATH, + scopes_supported: auth_settings.client_registration_options.valid_scopes, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported: ["client_secret_post"], + code_challenge_methods_supported: ["S256"], + **kwargs, + ) + + if auth_settings.client_registration_options.enabled + metadata.registration_endpoint = auth_settings.issuer_url + DEFAULT_REGISTRATION_PATH + end + + metadata + end + end end end end diff --git a/lib/mcp/auth/server/handlers/metadata_handler.rb b/lib/mcp/auth/server/handlers/metadata_handler.rb new file mode 100644 index 0000000..d7580d4 --- /dev/null +++ b/lib/mcp/auth/server/handlers/metadata_handler.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_relative "../../../serialization_utils" + +module MCP + module Auth + module Server + module Handlers + class MetadataHandler + include SerializationUtils + + def initialize(oauth_metadata) + @oauth_metadata = oauth_metadata + end + + # returns [status, headers, body] + def handle(request) + headers = { + "Cache-Control": "public, max-age=3600", + "Content-Type": "application/json", + } + [200, headers, to_h(@oauth_metadata)] + end + end + end + end + end +end diff --git a/lib/mcp/auth/server/provider.rb b/lib/mcp/auth/server/provider.rb new file mode 100644 index 0000000..bf9cd8a --- /dev/null +++ b/lib/mcp/auth/server/provider.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require_relative "../models" + +module MCP + module Auth + module Server + class AuthorizationParams + attr_accessor :state, + :scopes, + :code_challenge, + :redirect_uri, + :redirect_uri_provided_explicitly + + def initialize( + state: nil, + scopes: nil, + code_challenge:, + redirect_uri:, + redirect_uri_provided_explicitly: + ) + @state = state + @scopes = scopes + @code_challenge = code_challenge + @redirect_uri = redirect_uri + @redirect_uri_provided_explicitly = redirect_uri_provided_explicitly + end + end + + class AuthorizationCode + attr_accessor :code, + :scopes, + :expires_at, + :client_id, + :code_challenge, + :redirect_uri, + :redirect_uri_provided_explicitly + + def initialize( + code:, + scopes:, + expires_at:, + client_id:, + code_challenge:, + redirect_uri:, + redirect_uri_provided_explicitly: + ) + @code = code + @scopes = scopes + @expires_at = expires_at + @client_id = client_id + @code_challenge = code_challenge + @redirect_uri = redirect_uri + @redirect_uri_provided_explicitly = redirect_uri_provided_explicitly + end + end + + class RefreshToken + attr_accessor :token, + :client_id, + :scopes, + :expires_at + + def initialize( + token:, + client_id:, + scopes:, + expires_at: nil + ) + @token = token + @client_id = client_id + @scopes = scopes + @expires_at = expires_at + end + end + + class AccessToken + attr_accessor :token, + :client_id, + :scopes, + :expires_at + + def initialize( + token:, + client_id:, + scopes:, + expires_at: nil + ) + @token = token + @client_id = client_id + @scopes = scopes + @expires_at = expires_at + end + end + + class OAuthAuthorizationServerProvider + # Retrieves client information by client ID. + # Implementors MAY raise NotImplementedError if dynamic client registration is + # disabled in ClientRegistrationOptions. + # + # @param client_id [String] The ID of the client to retrieve. + # @return [MCP::Auth::Models::OAuthClientInformationFull, nil] The client information, or nil if the client does not exist. + def get_client(client_id) + raise NotImplementedError, "#{self.class.name}#get_client is not implemented" + end + + # Saves client information as part of registering it. + # Implementors MAY raise NotImplementedError if dynamic client registration is + # disabled in ClientRegistrationOptions. + # + # @param client_info [MCP::Auth::Models::OAuthClientInformationFull] The client metadata to register. + # @raise [MCP::Auth::Errors::RegistrationError] If the client metadata is invalid. + def register_client(client_info) + raise NotImplementedError, "#{self.class.name}#register_client is not implemented" + end + + # Called as part of the /authorize endpoint, and returns a URL that the client + # will be redirected to. + # Many MCP implementations will redirect to a third-party provider to perform + # a second OAuth exchange with that provider. In this sort of setup, the client + # has an OAuth connection with the MCP server, and the MCP server has an OAuth + # connection with the 3rd-party provider. At the end of this flow, the client + # should be redirected to the redirect_uri from params.redirect_uri. + # + # +--------+ +------------+ +-------------------+ + # | | | | | | + # | Client | --> | MCP Server | --> | 3rd Party OAuth | + # | | | | | Server | + # +--------+ +------------+ +-------------------+ + # | ^ | + # +------------+ | | | + # | | | | Redirect | + # |redirect_uri|<-----+ +------------------+ + # | | + # +------------+ + # + # Implementations will need to define another handler on the MCP server return + # flow to perform the second redirect, and generate and store an authorization + # code as part of completing the OAuth authorization step. + # + # Implementations SHOULD generate an authorization code with at least 160 bits of + # entropy, and MUST generate an authorization code with at least 128 bits of entropy. + # See https://datatracker.ietf.org/doc/html/rfc6749#section-10.10. + # + # @param client [MCP::Auth::Models::OAuthClientInformationFull] The client requesting authorization. + # @param params [MCP::Auth::Server::AuthorizationParams] The parameters of the authorization request. + # @return [String] A URL to redirect the client to for authorization. + # @raise [MCP::Auth::Errors::AuthorizeError] If the authorization request is invalid. + def authorize(client_info, params) + raise NotImplementedError, "#{self.class.name}#authorize is not implemented" + end + + # Loads an AuthorizationCode by its code string. + # + # @param client_info [MCP::Auth::Server::OAuthClientInformationFull] The client that requested the authorization code. + # @param authorization_code [String] The authorization code string to load. + # @return [MCP::Auth::Server::AuthorizationCode, nil] The AuthorizationCode object, or nil if not found. + def load_authorization_code(client_info, authorization_code) + raise NotImplementedError, "#{self.class.name}#load_authorization_code is not implemented" + end + + # Exchanges an authorization code for an access token and refresh token. + # + # @param client [MCP::Auth::Models::OAuthClientInformationFull] The client exchanging the authorization code. + # @param authorization_code [MCP::Auth::Server::AuthorizationCode] The authorization code object to exchange. + # @return [MCP::Auth::Models::OAuthToken] The OAuth token, containing access and refresh tokens. + # @raise [Mcp::Auth::Server::TokenError] If the request is invalid. + def exchange_authorization_code(client, authorization_code) + raise NotImplementedError, "#{self.class.name}#exchange_authorization_code is not implemented" + end + + # Loads a RefreshToken by its token string. + # + # @param client [Mcp::Shared::Auth::OAuthClientInformationFull] The client that is requesting to load the refresh token. + # @param refresh_token_str [String] The refresh token string to load. + # @return [Mcp::Auth::Server::RefreshToken, nil] The RefreshToken object if found, or nil if not found. + def load_refresh_token(client, refresh_token) + raise NotImplementedError, "#{self.class.name}#load_refresh_token is not implemented" + end + + # Exchanges a refresh token for an access token and (potentially new) refresh token. + # Implementations SHOULD rotate both the access token and refresh token. + # + # @param client [Mcp::Shared::Auth::OAuthClientInformationFull] The client exchanging the refresh token. + # @param refresh_token [Mcp::Auth::Server::RefreshToken] The refresh token object to exchange. + # @param scopes [Array] Optional scopes to request with the new access token. + # @return [Mcp::Shared::Auth::OAuthToken] The OAuth token, containing access and refresh tokens. + # @raise [Mcp::Auth::Server::TokenError] If the request is invalid. + def exchange_refresh_token(client, refresh_token, scopes) + raise NotImplementedError, "#{self.class.name}#exchange_refresh_token is not implemented" + end + + # Loads an access token by its token string. + # + # @param token_str [String] The access token string to verify. + # @return [Mcp::Auth::Server::AccessToken, nil] The AccessToken object, or nil if the token is invalid. + def load_access_token(token_str) + raise NotImplementedError, "#{self.class.name}#load_access_token is not implemented" + end + + # Revokes an access or refresh token. + # If the given token is invalid or already revoked, this method should do nothing. + # Implementations SHOULD revoke both the access token and its corresponding + # refresh token, regardless of which of the access token or refresh token is provided. + # + # @param token [Mcp::Auth::Server::AccessToken, Mcp::Auth::Server::RefreshToken] The token object to revoke. + # @return [void] + def revoke_token(token) + raise NotImplementedError, "#{self.class.name}#revoke_token is not implemented" + end + end + end + end +end diff --git a/lib/mcp/auth/server/settings.rb b/lib/mcp/auth/server/settings.rb new file mode 100644 index 0000000..45bb9c6 --- /dev/null +++ b/lib/mcp/auth/server/settings.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module MCP + module Auth + module Server + class ClientRegistrationOptions + attr_accessor :enabled, + :client_secret_expiry_seconds, + :valid_scopes, + :default_scopes + + def initialize( + enabled: false, + client_secret_expiry_seconds: nil, + valid_scopes: nil, + default_scopes: nil + ) + @enabled = enabled + @client_secret_expiry_seconds = client_secret_expiry_seconds + @valid_scopes = valid_scopes + @default_scopes = default_scopes + end + end + + class RevocationOptions + attr_accessor :enabled + + def initialize(enabled: false) + @enabled = enabled + end + end + + class AuthSettings + attr_accessor :issuer_url, + :service_documentation_url, + :client_registration_options, + :revocation_options, + :required_scopes + + def initialize( + issuer_url:, + service_documentation_url: nil, + client_registration_options: nil, + revocation_options: nil, + required_scopes: nil + ) + raise ArgumentError, "issuer_url is required" if issuer_url.nil? + + @issuer_url = issuer_url # this is the url the mcp server is reachable at + @service_documentation_url = service_documentation_url + @client_registration_options = client_registration_options || ClientRegistrationOptions.new + @revocation_options = revocation_options || RevocationOptions.new + @required_scopes = required_scopes + end + end + end + end +end diff --git a/lib/mcp/auth/server/uri_helper.rb b/lib/mcp/auth/server/uri_helper.rb new file mode 100644 index 0000000..1f464e7 --- /dev/null +++ b/lib/mcp/auth/server/uri_helper.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "uri" + +module MCP + module Auth + module Server + module UriHelper + # Constructs a redirect URI by adding parameters to a base URI. + # + # @param redirect_uri_base [String] The base URI. + # @param params [Hash] Parameters to add to the URI query. Nil values are omitted. + # Keys should be symbols or strings. + # @return [String] The constructed URI string. + def construct_redirect_uri(redirect_uri_base, **params) + uri = URI.parse(redirect_uri_base) + + query_pairs = params.reject do |_, v| + v.nil? || v.empty? + end + if uri.query && !uri.query.empty? + query_pairs.concat(URI.decode_www_form(uri.query)) + end + + uri.query = query_pairs.any? ? URI.encode_www_form(query_pairs) : nil + uri.to_s + end + end + end + end +end diff --git a/lib/mcp/serialization_utils.rb b/lib/mcp/serialization_utils.rb new file mode 100644 index 0000000..3bc120e --- /dev/null +++ b/lib/mcp/serialization_utils.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module MCP + module SerializationUtils + def to_h(obj) + obj.instance_variables.each_with_object({}) do |var, hash| + key = var.to_s.delete("@").to_sym + value = @oauth_metadata.instance_variable_get(var) + hash[key] = value unless value.nil? + end + end + end +end From 9535a060ba0ae06aac052d931358050842abe29b Mon Sep 17 00:00:00 2001 From: Mazine Mrini Date: Fri, 30 May 2025 14:52:13 -0400 Subject: [PATCH 3/6] Adds auth registration flow --- lib/mcp.rb | 7 +- lib/mcp/auth/errors.rb | 2 + lib/mcp/auth/server/client_registry.rb | 34 ++++ .../server/handlers/registration_handler.rb | 180 ++++++++++++++++++ .../mcp_authorization_server_provider.rb | 30 +++ lib/mcp/auth/server/request_parser.rb | 25 +++ lib/mcp/auth/server/settings.rb | 19 ++ lib/mcp/auth/server/state_registry.rb | 34 ++++ lib/mcp/serialization_utils.rb | 2 +- 9 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 lib/mcp/auth/server/client_registry.rb create mode 100644 lib/mcp/auth/server/handlers/registration_handler.rb create mode 100644 lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb create mode 100644 lib/mcp/auth/server/request_parser.rb create mode 100644 lib/mcp/auth/server/state_registry.rb diff --git a/lib/mcp.rb b/lib/mcp.rb index b4ee077..f08cc72 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -2,6 +2,7 @@ require_relative "mcp/server" require_relative "mcp/string_utils" +require_relative "mcp/serialization_utils" require_relative "mcp/tool" require_relative "mcp/tool/input_schema" require_relative "mcp/tool/annotations" @@ -23,8 +24,12 @@ require_relative "mcp/auth/server/provider" require_relative "mcp/auth/server/settings" require_relative "mcp/auth/server/uri_helper" +require_relative "mcp/auth/server/client_registry" +require_relative "mcp/auth/server/state_registry" +require_relative "mcp/auth/server/request_parser" +require_relative "mcp/auth/server/providers/mcp_authorization_server_provider" require_relative "mcp/auth/server/handlers/metadata_handler" -require_relative "mcp/serialization_utils" +require_relative "mcp/auth/server/handlers/registration_handler" module MCP class << self diff --git a/lib/mcp/auth/errors.rb b/lib/mcp/auth/errors.rb index 29139cd..f164a91 100644 --- a/lib/mcp/auth/errors.rb +++ b/lib/mcp/auth/errors.rb @@ -5,6 +5,8 @@ module Auth module Errors class InvalidScopeError < StandardError; end + class InvalidGrantsError < StandardError; end + class InvalidRedirectUriError < StandardError; end class RegistrationError < StandardError diff --git a/lib/mcp/auth/server/client_registry.rb b/lib/mcp/auth/server/client_registry.rb new file mode 100644 index 0000000..bf2d578 --- /dev/null +++ b/lib/mcp/auth/server/client_registry.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MCP + module Auth + module Server + class ClientRegistry + def create(client_info) + raise NotImplementedError, "Subclasses must implement" + end + + def find_by_id(client_id) + raise NotImplementedError, "Subclasses must implement" + end + end + + class InMemoryClientRegistry < ClientRegistry + def initialize + super + @clients = {} + end + + def create(client_info) + raise ArgumentError, "Client '#{client_info.client_id}' already exists" if @clients.key?(client_info.client_id) + + @clients[client_info.client_id] = client_info + end + + def find_by_id(client_id) + @clients[client_id] + end + end + end + end +end diff --git a/lib/mcp/auth/server/handlers/registration_handler.rb b/lib/mcp/auth/server/handlers/registration_handler.rb new file mode 100644 index 0000000..f835aed --- /dev/null +++ b/lib/mcp/auth/server/handlers/registration_handler.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require "securerandom" +require "time" +require "json" +require_relative "../../../serialization_utils" +require_relative "../../errors" +require_relative "../../models" + +module MCP + module Auth + module Server + module Handlers + class RegistrationHandler + include SerializationUtils + + def initialize( + auth_server_provider:, + client_registration_options:, + request_parser: + ) + @auth_server_provider = auth_server_provider + @client_registration_options = client_registration_options + @request_parser = request_parser + end + + def handle(request) + # Implements dynamic client registration as defined in https://datatracker.ietf.org/doc/html/rfc7591#section-3.1 + client_metadata_hash = @request_parser.parse_body(request) + client_metadata = Models::OAuthClientMetadata.new(**client_metadata_hash) + + client_id_issued_at = Time.now.to_i + client_info = Models::OAuthClientInformationFull.new( + client_id:, + client_id_issued_at:, + client_secret: client_secret(client_metadata), + client_secret_expires_at: client_secret_expires_at(client_metadata, client_id_issued_at), + # passthrough information from the client request + redirect_uris: client_metadata.redirect_uris, + token_endpoint_auth_method: client_metadata.token_endpoint_auth_method, + grant_types: grant_types!(client_metadata), + response_types: client_metadata.response_types, + client_name: client_metadata.client_name, + client_uri: client_metadata.client_uri, + logo_uri: client_metadata.logo_uri, + scope: scope!(client_metadata), + contacts: client_metadata.contacts, + tos_uri: client_metadata.tos_uri, + policy_uri: client_metadata.policy_uri, + jwks_uri: client_metadata.jwks_uri, + jwks: client_metadata.jwks, + software_id: client_metadata.software_id, + software_version: client_metadata.software_version, + ) + + @auth_server_provider.register_client(client_info) + + # See RFC https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1 for format + [201, { "Content-Type": "application/json" }, to_h(client_info)] + rescue Errors::RegistrationError => e + error_response( + status: 400, + error: e.error_code, + error_description: e.message, + ) + rescue => e + error_response( + status: 400, + error: Errors::RegistrationError::INVALID_CLIENT_METADATA, + error_description: e.message, + ) + end + + private + + def client_id + SecureRandom.uuid_v4 + end + + def client_secret(client_metadata) + if client_metadata.token_endpoint_auth_method == "none" + return + end + + SecureRandom.hex(32) + end + + def client_secret_expires_at(client_metadata, issued_at) + if @client_registration_options.client_secret_expiry_seconds + issued_at + @client_registration_options.client_secret_expiry_seconds + end + + nil + end + + def scope!(client_metadata) + if client_metadata.scope.nil? && @client_registration_options.default_scopes + return client_registration_options.default_scopes.join(" ") + end + + if client_metadata.scope + requested_scopes = client_metadata.scope.split + @client_registration_options.validate_scopes!(requested_scopes) + end + + client_metadata.scope + rescue Errors::InvalidScopeError => e + raise e.message + end + + def grant_types!(client_metadata) + @client_registration_options.validate_grant_types!(client_metadata.grant_types || []) + + client_metadata.grant_types + rescue InvalidGrantsError => e + raise e.message + end + + def error_response(status:, error:, error_description:) + # See RFC https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2 for format + headers = { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + } + body = { error:, error_description: } + + [status, headers, body] + end + end + # + # class MyOAuthAuthorizationServerProvider + # def register_client(client_info) + # puts "Registering client: #{client_info[:client_id]}" + # # Raise RegistrationError.new('invalid_redirect_uri', 'One of the redirect_uris is invalid.') + # # Or succeed + # end + # end + # + # class ClientRegistrationOptions + # def self.defaults + # { + # default_scopes: ['openid', 'profile', 'email'], + # valid_scopes: ['openid', 'profile', 'email', 'read:data', 'write:data'], + # client_secret_expiry_seconds: 3600 * 24 * 30 # 30 days + # } + # end + # end + # + # # --- In a Rack app --- + # # require 'rack' + # # + # # class App + # # def initialize + # # @provider = MyOAuthAuthorizationServerProvider.new + # # @options = ClientRegistrationOptions.defaults + # # @registration_handler = RegistrationHandler.new(@provider, @options) + # # end + # # + # # def call(env) + # # request = Rack::Request.new(env) + # # if request.path_info == '/register' && request.post? + # # # Assuming async is handled by the server (e.g., Puma with async.callback) + # # # For simplicity, calling it synchronously here: + # # status, headers, body = @registration_handler.handle(request) + # # Rack::Response.new(body, status, headers).finish + # # else + # # [404, { 'Content-Type' => 'text/plain' }, ['Not Found']] + # # end + # # end + # # end + # + # # To run this example (simplified): + # # app = App.new + # # server = Rack::Handler::WEBrick + # # server.run app, Port: 9292 + end + end + end +end diff --git a/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb b/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb new file mode 100644 index 0000000..16516ff --- /dev/null +++ b/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative "../client_registry" +require_relative "../state_registry" + +module MCP + module Auth + module Server + module Providers + class McpAuthorizationServerProvider < OAuthAuthorizationServerProvider + def initialize( + client_registry: nil, + state_registry: nil + ) + @client_registry = client_registry || InMemoryClientRegistry.new + @state_registry = state_registry || InMemoryStateRegistry.new + end + + def get_client(client_id) + @client_registry.find_by_id(client_id) + end + + def register_client(client_info) + @client_registry.create(client_info) + end + end + end + end + end +end diff --git a/lib/mcp/auth/server/request_parser.rb b/lib/mcp/auth/server/request_parser.rb new file mode 100644 index 0000000..5280c32 --- /dev/null +++ b/lib/mcp/auth/server/request_parser.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module MCP + module Auth + module Server + class RequestParser + # Parses the body of a request object into a hash. + # + # @param request [Object] The request object to parse + # @return [Hash] The parsed body + def parse_body(request) + raise NotImplementedError, "Subclass must implement" + end + + # Parses a request object into a hash of parameters. + # + # @param request [Object] The request object to parse + # @return [Hash] The parsed params + def parse_request_params(request) + raise NotImplementedError, "Subclass must implement" + end + end + end + end +end diff --git a/lib/mcp/auth/server/settings.rb b/lib/mcp/auth/server/settings.rb index 45bb9c6..c12b35d 100644 --- a/lib/mcp/auth/server/settings.rb +++ b/lib/mcp/auth/server/settings.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true +require_relative "../errors" + module MCP module Auth module Server class ClientRegistrationOptions + MANDATORY_GRANT_TYPES = Set["authorization_code", "refresh_token"].freeze + attr_accessor :enabled, :client_secret_expiry_seconds, :valid_scopes, @@ -20,6 +24,21 @@ def initialize( @valid_scopes = valid_scopes @default_scopes = default_scopes end + + def validate_grant_types!(grant_types) + if grant_types.to_set != MANDATORY_GRANT_TYPES + raise Errors::InvalidGrantsError, "Grants must be '#{MANDATORY_GRANT_TYPES.to_a}'" + end + end + + def validate_scopes!(requested_scopes) + return if valid_scopes.nil? + + invalid_scopes = requested_scopes - valid_scopes + if invalid_scopes.any? + raise Errors::InvalidScopeError, "Some requested scopes are invalid: #{invalid_scopes.join(", ")}" + end + end end class RevocationOptions diff --git a/lib/mcp/auth/server/state_registry.rb b/lib/mcp/auth/server/state_registry.rb new file mode 100644 index 0000000..fb4e9bc --- /dev/null +++ b/lib/mcp/auth/server/state_registry.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MCP + module Auth + module Server + class StateRegistry + def create(state_id, state) + raise NotImplementedError, "Subclasses must implement" + end + + def find_by_id(state_id) + raise NotImplementedError, "Subclasses must implement" + end + end + + class InMemoryStateRegistry < StateRegistry + def initialize + super + @states = {} + end + + def create(state_id, state) + raise ArgumentError, "State with id '#{state_id}' already exists" if @states.key?(state_id) + + @states[state_id] = state + end + + def find_by_id(state_id) + @states[state_id] + end + end + end + end +end diff --git a/lib/mcp/serialization_utils.rb b/lib/mcp/serialization_utils.rb index 3bc120e..72ec15a 100644 --- a/lib/mcp/serialization_utils.rb +++ b/lib/mcp/serialization_utils.rb @@ -5,7 +5,7 @@ module SerializationUtils def to_h(obj) obj.instance_variables.each_with_object({}) do |var, hash| key = var.to_s.delete("@").to_sym - value = @oauth_metadata.instance_variable_get(var) + value = obj.instance_variable_get(var) hash[key] = value unless value.nil? end end From 6c8384257f196cb73edee1e0c3b8de5498ce5e54 Mon Sep 17 00:00:00 2001 From: Mazine Mrini Date: Sun, 1 Jun 2025 20:11:33 -0400 Subject: [PATCH 4/6] Adds authorization handling mechanisms --- lib/mcp.rb | 1 + lib/mcp/auth/errors.rb | 8 + lib/mcp/auth/models.rb | 23 +-- .../server/handlers/authorization_handler.rb | 192 ++++++++++++++++++ lib/mcp/auth/server/provider.rb | 19 +- .../mcp_authorization_server_provider.rb | 52 ++++- lib/mcp/auth/server/request_parser.rb | 16 +- 7 files changed, 281 insertions(+), 30 deletions(-) create mode 100644 lib/mcp/auth/server/handlers/authorization_handler.rb diff --git a/lib/mcp.rb b/lib/mcp.rb index f08cc72..4a951e0 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -30,6 +30,7 @@ require_relative "mcp/auth/server/providers/mcp_authorization_server_provider" require_relative "mcp/auth/server/handlers/metadata_handler" require_relative "mcp/auth/server/handlers/registration_handler" +require_relative "mcp/auth/server/handlers/authorization_handler" module MCP class << self diff --git a/lib/mcp/auth/errors.rb b/lib/mcp/auth/errors.rb index f164a91..e7ea49f 100644 --- a/lib/mcp/auth/errors.rb +++ b/lib/mcp/auth/errors.rb @@ -9,6 +9,8 @@ class InvalidGrantsError < StandardError; end class InvalidRedirectUriError < StandardError; end + class MissingClientIdError < StandardError; end + class RegistrationError < StandardError INVALID_REDIRECT_URI = "invalid_redirect_uri" INVALID_CLIENT_METADATA = "invalid_client_metadata" @@ -38,6 +40,12 @@ def initialize(error_code:, message: nil) super(message) @error_code = error_code end + + class << self + def invalid_request(message) + AuthorizationError.new(error_code: INVALID_REQUEST, message:) + end + end end class TokenError < StandardError diff --git a/lib/mcp/auth/models.rb b/lib/mcp/auth/models.rb index a38c3c2..dbc35a2 100644 --- a/lib/mcp/auth/models.rb +++ b/lib/mcp/auth/models.rb @@ -115,10 +115,7 @@ def initialize( @software_version = software_version end - def validate_scope(requested_scope) - return if requested_scope.nil? || requested_scope.empty? - - requested_scopes = requested_scope.split(" ") + def validate_scopes!(requested_scopes) allowed_scopes = @scope.nil? ? [] : @scope.split(" ") requested_scopes.each do |s| @@ -126,22 +123,14 @@ def validate_scope(requested_scope) raise Errors::InvalidScopeError, "Client was not registered with scope '#{s}'" end end - - requested_scopes end - def validate_redirect_uri(redirect_uri) - if redirect_uri - unless @redirect_uris.include?(redirect_uri) - raise Errors::InvalidRedirectUriError, "Redirect URI '#{redirect_uri}' not registered for client" - end + def valid_redirect_uri?(redirect_uri) + @redirect_uris.include?(redirect_uri) + end - redirect_uri_str - elsif @redirect_uris.one? - @redirect_uris.first - else - raise Errors::InvalidRedirectUriError, "redirect_uri must be specified when client has multiple registered URIs" - end + def multiple_redirect_uris? + @redirect_uris.size > 1 end end diff --git a/lib/mcp/auth/server/handlers/authorization_handler.rb b/lib/mcp/auth/server/handlers/authorization_handler.rb new file mode 100644 index 0000000..9157ea4 --- /dev/null +++ b/lib/mcp/auth/server/handlers/authorization_handler.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require_relative "../../errors" +require_relative "../../server/provider" + +module MCP + module Auth + module Server + module Handlers + class AuthorizationRequest + attr_reader :client_id, + :redirect_uri, + :code_challenge_method, + :code_challenge, + :response_type, + :state, + :scope + + def initialize( + client_id: nil, + redirect_uri: nil, + code_challenge_method: nli, + response_type: nil, + code_challenge: nil, + state: nil, + scope: nil + ) + if client_id.nil? + raise Errors::AuthorizationError.invalid_request("client_id must be defined") + end + + if response_type != "code" + raise Errors::AuthorizationError.new( + error_code: Errors::AuthorizationError::UNSUPPORTED_RESPONSE_TYPE, + message: "response_type must be 'code'", + ) + end + + if code_challenge_method != "S256" + raise Errors::AuthorizationError.invalid_request("code_challenge_method must be 'S256'") + end + + if code_challenge.nil? + raise Errors::AuthorizationError.invalid_request("code_challenge must be defined") + end + + @client_id = client_id + @code_challenge = code_challenge + @code_challenge_method = code_challenge_method + @response_type = response_type + @redirect_uri = redirect_uri + @state = state + @scope = scope + end + + def scopes_array + return [] if @scope.nil? + + @scope.split(" ") + end + + def redirect_uri_provided? + !@redirect_uri.nil? + end + end + + class AuthorizationHandler + def initialize( + auth_server_provider:, + request_parser: + ) + @auth_server_provider = auth_server_provider + @request_parser = request_parser + end + + def handle(request) + # implements authorization requests for grant_type=code; + # See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 + # For error handling, refer to https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 + params_h = as_params_h(request) || {} + client_info, redirect_uri, auth_error = get_client_and_redirect_uri(params_h) + return bad_request_error(params_h:, auth_error:) if auth_error + + begin + auth_request = AuthorizationRequest.new(**params_h) + scopes = auth_request.scopes_array + client_info.validate_scopes!(scopes) + + auth_params = AuthorizationParams.new( + state: auth_request.state, + scopes:, + code_challenge: auth_request.code_challenge, + redirect_uri:, + redirect_uri_provided_explicitly: auth_request.redirect_uri_provided?, + response_type: auth_request.response_type, + ) + + location = @auth_server_provider.authorize(client_info:, auth_params:) + headers = { + "Cache-Control": "no-store", + "Location": location, + } + [302, headers, nil] + rescue => e + error_code = case e + in Errors::InvalidScopeError then Errors::AuthorizationError::INVALID_SCOPE + in Errors::AuthorizationError then e.error_code + else + Errors::AuthorizationError::SERVER_ERROR + end + + redirect_response_error( + redirect_uri:, + error_code:, + error_description: e.message, + params_h:, + ) + end + end + + private + + def as_params_h(request) + @request_parser.get?(request) ? @request_parser.parse_query_params(request) : @request_parser.parse_body(request) + end + + # Validates the client_id and redirect_uri parameters from the authorization request. + # Returns a tuple of [client_info, redirect_uri, error] where: + # - client_info is the OAuthClientInformationFull for the client if found and valid + # - redirect_uri is a string derived from the params and client + # - error is an AuthorizationError if validation fails, nil otherwise + # + # @param params_h [Hash] The authorization request parameters + # @return [OAuthClientInformationFull, String, AuthorizationError] Tuple of client_info, redirect_uri, error + def get_client_and_redirect_uri(params_h) + client_id = params_h[:client_id] + if client_id.nil? + return [nil, nil, Errors::AuthorizationError.invalid_request("client_id must be defined")] + end + + client_info = @auth_server_provider.get_client(client_id) + if client_info.nil? + return [nil, nil, Errors::AuthorizationError.invalid_request("client '#{client_id}' not found")] + end + + redirect_uri = params_h[:redirect_uri] + if client_info.multiple_redirect_uris? && redirect_uri.nil? + return [nil, nil, Errors::AuthorizationError.invalid_request("redirect_uri must be defined because client defines multiple options")] + end + + redirect_uri ||= client_info.redirect_uris.first + if redirect_uri.nil? + return [ + nil, + nil, + Errors::AuthorizationError.new( + error_code: Errors::AuthorizationError::SERVER_ERROR, message: "unable to select a redirect_uri", + ), + ] + end + + unless client_info.valid_redirect_uri?(redirect_uri) + return [client_info, nil, Errors::AuthorizationError.invalid_request("invalid redirect_uri")] + end + + [client_info, redirect_uri, nil] + end + + def redirect_response_error( + redirect_uri:, + error_code:, + error_description:, + params_h: + ) + end + + def bad_request_error( + params_h:, + auth_error: + ) + body = { error: auth_error.error_code, error_description: auth_error.message } + if params_h[:state] + body[:state] = params_h[:state] + end + + [400, { "Cache-Control": "no-store" }, body] + end + end + end + end + end +end diff --git a/lib/mcp/auth/server/provider.rb b/lib/mcp/auth/server/provider.rb index bf9cd8a..e8a8c52 100644 --- a/lib/mcp/auth/server/provider.rb +++ b/lib/mcp/auth/server/provider.rb @@ -10,18 +10,21 @@ class AuthorizationParams :scopes, :code_challenge, :redirect_uri, - :redirect_uri_provided_explicitly + :redirect_uri_provided_explicitly, + :response_type def initialize( - state: nil, - scopes: nil, code_challenge:, redirect_uri:, - redirect_uri_provided_explicitly: + redirect_uri_provided_explicitly:, + response_type:, + state: nil, + scopes: nil ) @state = state @scopes = scopes @code_challenge = code_challenge + @response_type = response_type @redirect_uri = redirect_uri @redirect_uri_provided_explicitly = redirect_uri_provided_explicitly end @@ -93,7 +96,7 @@ def initialize( end end - class OAuthAuthorizationServerProvider + module OAuthAuthorizationServerProvider # Retrieves client information by client ID. # Implementors MAY raise NotImplementedError if dynamic client registration is # disabled in ClientRegistrationOptions. @@ -142,11 +145,11 @@ def register_client(client_info) # entropy, and MUST generate an authorization code with at least 128 bits of entropy. # See https://datatracker.ietf.org/doc/html/rfc6749#section-10.10. # - # @param client [MCP::Auth::Models::OAuthClientInformationFull] The client requesting authorization. - # @param params [MCP::Auth::Server::AuthorizationParams] The parameters of the authorization request. + # @param client_info [MCP::Auth::Models::OAuthClientInformationFull] The client requesting authorization. + # @param params [MCP::Auth::Server::AuthorizationParams] The parameters of the authorization request. # @return [String] A URL to redirect the client to for authorization. # @raise [MCP::Auth::Errors::AuthorizeError] If the authorization request is invalid. - def authorize(client_info, params) + def authorize(client_info:, auth_params:) raise NotImplementedError, "#{self.class.name}#authorize is not implemented" end diff --git a/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb b/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb index 16516ff..8ea7f55 100644 --- a/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb +++ b/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb @@ -1,17 +1,50 @@ # frozen_string_literal: true +require "securerandom" +require_relative "../../../serialization_utils" require_relative "../client_registry" require_relative "../state_registry" +require_relative "../provider" module MCP module Auth module Server module Providers - class McpAuthorizationServerProvider < OAuthAuthorizationServerProvider + class McpAuthServerSettings + attr_reader :client_id, + :client_secret, + :auth_server_scopes, + :auth_server_authorization_endpoint, + :auth_server_token_endpoint, + :mcp_callback_endpoint + + def initialize( + client_id:, + client_secret:, + auth_server_scopes:, + auth_server_authorization_endpoint:, + auth_server_token_endpoint:, + mcp_callback_endpoint: + ) + @client_id = client_id + @client_secret = client_secret + @auth_server_scopes = auth_server_scopes + @auth_server_authorization_endpoint = auth_server_authorization_endpoint + @auth_server_token_endpoint = auth_server_token_endpoint + @mcp_callback_endpoint = mcp_callback_endpoint + end + end + + class McpAuthorizationServerProvider + include OAuthAuthorizationServerProvider + include SerializationUtils + def initialize( + auth_server_settings:, client_registry: nil, state_registry: nil ) + @settings = auth_server_settings @client_registry = client_registry || InMemoryClientRegistry.new @state_registry = state_registry || InMemoryStateRegistry.new end @@ -23,6 +56,23 @@ def get_client(client_id) def register_client(client_info) @client_registry.create(client_info) end + + def authorize(client_info:, auth_params:) + state = auth_params.state || SecureRandom.hex(16) + + @state_registry.create(state, to_h(auth_params)) + + auth_url = URI(@settings.auth_server_authorization_endpoint) + auth_url.query = URI.encode_www_form([ + ["client_id", @settings.client_id], + ["redirect_uri", @settings.mcp_callback_endpoint], + ["scope", @settings.auth_server_scopes], + ["state", state], + ["response_type", auth_params.response_type], + ]) + + auth_url.to_s + end end end end diff --git a/lib/mcp/auth/server/request_parser.rb b/lib/mcp/auth/server/request_parser.rb index 5280c32..7b105de 100644 --- a/lib/mcp/auth/server/request_parser.rb +++ b/lib/mcp/auth/server/request_parser.rb @@ -9,15 +9,23 @@ class RequestParser # @param request [Object] The request object to parse # @return [Hash] The parsed body def parse_body(request) - raise NotImplementedError, "Subclass must implement" + raise NotImplementedError, "#{self.class.name}#parse_body is not implemented" end - # Parses a request object into a hash of parameters. + # Parses a request object query parameters into a hash of parameters. # # @param request [Object] The request object to parse # @return [Hash] The parsed params - def parse_request_params(request) - raise NotImplementedError, "Subclass must implement" + def parse_query_params(request) + raise NotImplementedError, "#{self.class.name}#parse_query_params is not implemented" + end + + # Checks whether the request is using the GET method + # + # @param request [Object] The request object to parse + # @return [Boolean] true when the request is using the GET method, false otherwise + def get?(request) + raise NotImplementedError, "#{self.class.name}#get? is not implemented" end end end From 32b1d343c794415c789f5a4d221485cc579e6a2f Mon Sep 17 00:00:00 2001 From: Mazine Mrini Date: Mon, 2 Jun 2025 21:01:47 -0400 Subject: [PATCH 5/6] Adds authorization callback handler --- lib/mcp.rb | 8 +- lib/mcp/auth/server/client_registry.rb | 34 -------- .../server/handlers/authorization_handler.rb | 1 - .../auth/server/handlers/callback_handler.rb | 51 +++++++++++ .../server/handlers/registration_handler.rb | 1 - lib/mcp/auth/server/provider.rb | 16 ++++ .../mcp_authorization_server_provider.rb | 85 ++++++++++++++++--- .../server/registries/auth_code_registry.rb | 23 +++++ .../auth/server/registries/client_registry.rb | 19 +++++ .../server/registries/in_memory_registry.rb | 80 +++++++++++++++++ .../auth/server/registries/state_registry.rb | 23 +++++ .../auth/server/registries/token_registry.rb | 23 +++++ lib/mcp/auth/server/state_registry.rb | 34 -------- lib/mcp/serialization_utils.rb | 6 ++ 14 files changed, 322 insertions(+), 82 deletions(-) delete mode 100644 lib/mcp/auth/server/client_registry.rb create mode 100644 lib/mcp/auth/server/handlers/callback_handler.rb create mode 100644 lib/mcp/auth/server/registries/auth_code_registry.rb create mode 100644 lib/mcp/auth/server/registries/client_registry.rb create mode 100644 lib/mcp/auth/server/registries/in_memory_registry.rb create mode 100644 lib/mcp/auth/server/registries/state_registry.rb create mode 100644 lib/mcp/auth/server/registries/token_registry.rb delete mode 100644 lib/mcp/auth/server/state_registry.rb diff --git a/lib/mcp.rb b/lib/mcp.rb index 4a951e0..cc87711 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -24,13 +24,17 @@ require_relative "mcp/auth/server/provider" require_relative "mcp/auth/server/settings" require_relative "mcp/auth/server/uri_helper" -require_relative "mcp/auth/server/client_registry" -require_relative "mcp/auth/server/state_registry" +require_relative "mcp/auth/server/registries/client_registry" +require_relative "mcp/auth/server/registries/state_registry" +require_relative "mcp/auth/server/registries/auth_code_registry" +require_relative "mcp/auth/server/registries/token_registry" +require_relative "mcp/auth/server/registries/in_memory_registry" require_relative "mcp/auth/server/request_parser" require_relative "mcp/auth/server/providers/mcp_authorization_server_provider" require_relative "mcp/auth/server/handlers/metadata_handler" require_relative "mcp/auth/server/handlers/registration_handler" require_relative "mcp/auth/server/handlers/authorization_handler" +require_relative "mcp/auth/server/handlers/callback_handler" module MCP class << self diff --git a/lib/mcp/auth/server/client_registry.rb b/lib/mcp/auth/server/client_registry.rb deleted file mode 100644 index bf2d578..0000000 --- a/lib/mcp/auth/server/client_registry.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module MCP - module Auth - module Server - class ClientRegistry - def create(client_info) - raise NotImplementedError, "Subclasses must implement" - end - - def find_by_id(client_id) - raise NotImplementedError, "Subclasses must implement" - end - end - - class InMemoryClientRegistry < ClientRegistry - def initialize - super - @clients = {} - end - - def create(client_info) - raise ArgumentError, "Client '#{client_info.client_id}' already exists" if @clients.key?(client_info.client_id) - - @clients[client_info.client_id] = client_info - end - - def find_by_id(client_id) - @clients[client_id] - end - end - end - end -end diff --git a/lib/mcp/auth/server/handlers/authorization_handler.rb b/lib/mcp/auth/server/handlers/authorization_handler.rb index 9157ea4..d68f140 100644 --- a/lib/mcp/auth/server/handlers/authorization_handler.rb +++ b/lib/mcp/auth/server/handlers/authorization_handler.rb @@ -94,7 +94,6 @@ def handle(request) redirect_uri_provided_explicitly: auth_request.redirect_uri_provided?, response_type: auth_request.response_type, ) - location = @auth_server_provider.authorize(client_info:, auth_params:) headers = { "Cache-Control": "no-store", diff --git a/lib/mcp/auth/server/handlers/callback_handler.rb b/lib/mcp/auth/server/handlers/callback_handler.rb new file mode 100644 index 0000000..f06268e --- /dev/null +++ b/lib/mcp/auth/server/handlers/callback_handler.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "../../errors" +require_relative "../../server/provider" + +module MCP + module Auth + module Server + module Handlers + class CallbackHandler + def initialize( + auth_server_provider:, + request_parser: + ) + @auth_server_provider = auth_server_provider + @request_parser = request_parser + end + + def handle(request) + params_h = @request_parser.parse_query_params(request) + code = params_h[:code] + state = params_h[:state] + if code.nil? || state.nil? + return bad_request_error(error: "invalid_request", error_description: "missing code or state parameter") + end + + begin + redirect_uri = @auth_server_provider.authorize_callback(code:, state:) + headers = { Location: redirect_uri } + + [302, headers, nil] + rescue Errors::AuthorizationError => e + bad_request_error( + error: e.error_code, + error_description: e.message, + ) + rescue + [500, {}, { error: "server_error" }] + end + end + + private + + def bad_request_error(error:, error_description:) + [400, {}, { error:, error_description: }] + end + end + end + end + end +end diff --git a/lib/mcp/auth/server/handlers/registration_handler.rb b/lib/mcp/auth/server/handlers/registration_handler.rb index f835aed..33a4475 100644 --- a/lib/mcp/auth/server/handlers/registration_handler.rb +++ b/lib/mcp/auth/server/handlers/registration_handler.rb @@ -2,7 +2,6 @@ require "securerandom" require "time" -require "json" require_relative "../../../serialization_utils" require_relative "../../errors" require_relative "../../models" diff --git a/lib/mcp/auth/server/provider.rb b/lib/mcp/auth/server/provider.rb index e8a8c52..b7ccc32 100644 --- a/lib/mcp/auth/server/provider.rb +++ b/lib/mcp/auth/server/provider.rb @@ -153,6 +153,22 @@ def authorize(client_info:, auth_params:) raise NotImplementedError, "#{self.class.name}#authorize is not implemented" end + # Handles the callback from the OAuth provider after the user has authorized + # the application. This is called when the OAuth provider redirects back to + # the MCP server's callback endpoint. + # + # Implementations should validate the code and state parameters, exchange the + # authorization code with the OAuth provider if needed, and return a URL to + # redirect the user back to the original client application. + # + # @param code [String] The authorization code from the OAuth provider + # @param state [String] The state parameter that was passed to the authorize endpoint + # @return [String] The URL to redirect the user to (typically the client's redirect_uri) + # @raise [MCP::Auth::Errors::AuthorizeError] If the callback parameters are invalid + def authorize_callback(code:, state:) + raise NotImplementedError, "#{self.class.name}#handle_callback is not implemented" + end + # Loads an AuthorizationCode by its code string. # # @param client_info [MCP::Auth::Server::OAuthClientInformationFull] The client that requested the authorization code. diff --git a/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb b/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb index 8ea7f55..5facb0a 100644 --- a/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb +++ b/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true require "securerandom" +require "net/http" require_relative "../../../serialization_utils" -require_relative "../client_registry" -require_relative "../state_registry" require_relative "../provider" module MCP @@ -39,28 +38,33 @@ class McpAuthorizationServerProvider include OAuthAuthorizationServerProvider include SerializationUtils + FIVE_MINUTES_IN_SECONDS = 300 + def initialize( auth_server_settings:, - client_registry: nil, - state_registry: nil + client_registry:, + state_registry:, + auth_code_registry:, + token_registry: ) @settings = auth_server_settings - @client_registry = client_registry || InMemoryClientRegistry.new - @state_registry = state_registry || InMemoryStateRegistry.new + @client_registry = client_registry + @state_registry = state_registry + @auth_code_registry = auth_code_registry + @token_registry = token_registry end def get_client(client_id) - @client_registry.find_by_id(client_id) + @client_registry.find_client(client_id) end def register_client(client_info) - @client_registry.create(client_info) + @client_registry.create_client(client_info) end def authorize(client_info:, auth_params:) state = auth_params.state || SecureRandom.hex(16) - - @state_registry.create(state, to_h(auth_params)) + @state_registry.create_state(state, to_h(auth_params)) auth_url = URI(@settings.auth_server_authorization_endpoint) auth_url.query = URI.encode_www_form([ @@ -73,6 +77,67 @@ def authorize(client_info:, auth_params:) auth_url.to_s end + + def authorize_callback(code:, state:) + state_data = @state_registry.find_state(state) + raise Errors::AuthorizationError.invalid_request("invalid state parameter") if state_data.nil? + + access_token = query_access_token!({ + client_id: @settings.client_id, + client_secret: @settings.client_secret, + code:, + redirect_uri: @settings.mcp_callback_endpoint, + grant_type: "authorization_code", + }) + + mcp_auth_code = "mcp_#{SecureRandom.hex(16)}" + auth_code = MCP::Auth::Server::AuthorizationCode.new( + code: mcp_auth_code, + client_id: state_data[:client_id], + redirect_uri: state_data[:redirect_uri], + redirect_uri_provided_explicitly: state_data[:redirect_uri_provided_explicitly], + expires_at: Time.now.to_i + FIVE_MINUTES_IN_SECONDS, + scopes: ["mcp:user"], + code_challenge: state_data[:code_challenge], + ) + @auth_code_registry.create_auth_code(mcp_auth_code, auth_code) + @token_registry.create_token(mcp_auth_code, AccessToken.new( + token: access_token, + client_id: state_data[:client_id], + scopes: @settings.auth_server_scopes, + expires_at: nil, + )) + + redirect_uri = URI(state_data[:redirect_uri]) + redirect_uri.query = URI.encode_www_form([ + ["code", mcp_auth_code], + ["state", state], + ]) + @state_registry.delete_state(state) + + redirect_uri + end + + private + + def query_access_token!(data) + uri = URI(@settings.auth_server_token_endpoint) + response = Net::HTTP.post_form(uri, stringify_keys(data)) + raise Errors::AuthorizationError.new( + error_code: Errors::AuthorizationError::INVALID_REQUEST, + message: "failed to exchange code for token", + ) unless response.is_a?(Net::HTTPOK) + + data = JSON.parse(response.body) + if data.key?("error") + raise Errors::AuthorizationError.new( + error_code: data["error"], + message: data["error_description"] || data["error"], + ) + end + + data["access_token"] + end end end end diff --git a/lib/mcp/auth/server/registries/auth_code_registry.rb b/lib/mcp/auth/server/registries/auth_code_registry.rb new file mode 100644 index 0000000..84102d8 --- /dev/null +++ b/lib/mcp/auth/server/registries/auth_code_registry.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module MCP + module Auth + module Server + module Registries + module AuthCodeRegistry + def create_auth_code(code_id, data) + raise NotImplementedError, "#{self.class.name}#create_auth_code is not implemented" + end + + def find_auth_code(code_id) + raise NotImplementedError, "#{self.class.name}#find_auth_code is not implemented" + end + + def delete_auth_code(code_id) + raise NotImplementedError, "#{self.class.name}#delete_auth_code is not implemented" + end + end + end + end + end +end diff --git a/lib/mcp/auth/server/registries/client_registry.rb b/lib/mcp/auth/server/registries/client_registry.rb new file mode 100644 index 0000000..557a610 --- /dev/null +++ b/lib/mcp/auth/server/registries/client_registry.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module MCP + module Auth + module Server + module Registries + module ClientRegistry + def create_client(client_info) + raise NotImplementedError, "#{self.class.name}#create_client is not implemented" + end + + def find_client(client_id) + raise NotImplementedError, "#{self.class.name}#find_client is not implemented" + end + end + end + end + end +end diff --git a/lib/mcp/auth/server/registries/in_memory_registry.rb b/lib/mcp/auth/server/registries/in_memory_registry.rb new file mode 100644 index 0000000..8564772 --- /dev/null +++ b/lib/mcp/auth/server/registries/in_memory_registry.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative "auth_code_registry" +require_relative "client_registry" +require_relative "state_registry" +require_relative "token_registry" + +module MCP + module Auth + module Server + module Registries + class InMemoryRegistry + include AuthCodeRegistry + include ClientRegistry + include StateRegistry + include TokenRegistry + + def initialize + @codes = {} + @clients = {} + @states = {} + @tokens = {} + end + + def create_client(client_info) + raise ArgumentError, "Client '#{client_info.client_id}' already exists" if @clients.key?(client_info.client_id) + + @clients[client_info.client_id] = client_info + end + + def find_client(client_id) + @clients[client_id] + end + + def create_auth_code(code_id, data) + raise ArgumentError, "Code with id '#{code}' already exists" if @codes.key?(code_id) + + @codes[code_id] = data + end + + def find_code(code_id) + @codes[code_id] + end + + def delete_code(code_id) + @codes.delete(code_id) + end + + def create_state(state_id, state) + raise ArgumentError, "State with id '#{state_id}' already exists" if @states.key?(state_id) + + @states[state_id] = state + end + + def find_state(state_id) + @states[state_id] + end + + def delete_state(state_id) + @states.delete(state_id) + end + + def create_token(token_id, token) + raise ArgumentError, "Token with id '#{token_id}' already exists" if @tokens.key?(token_id) + + @tokens[token_id] = token + end + + def find_token(token_id) + @tokens[token_id] + end + + def delete_token(token_id) + @tokens.delete(token_id) + end + end + end + end + end +end diff --git a/lib/mcp/auth/server/registries/state_registry.rb b/lib/mcp/auth/server/registries/state_registry.rb new file mode 100644 index 0000000..c34e6dd --- /dev/null +++ b/lib/mcp/auth/server/registries/state_registry.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module MCP + module Auth + module Server + module Registries + module StateRegistry + def create_state(state_id, state) + raise NotImplementedError, "#{self.class.name}#create_state is not implemented" + end + + def find_state(state_id) + raise NotImplementedError, "#{self.class.name}#find_state is not implemented" + end + + def delete_state(state_id) + raise NotImplementedError, "#{self.class.name}#delete_state is not implemented" + end + end + end + end + end +end diff --git a/lib/mcp/auth/server/registries/token_registry.rb b/lib/mcp/auth/server/registries/token_registry.rb new file mode 100644 index 0000000..7fe9851 --- /dev/null +++ b/lib/mcp/auth/server/registries/token_registry.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module MCP + module Auth + module Server + module Registries + module TokenRegistry + def create_token(token_id, state) + raise NotImplementedError, "#{self.class.name}#create_token is not implemented" + end + + def find_token(token_id) + raise NotImplementedError, "#{self.class.name}#find_token is not implemented" + end + + def delete_token(token_id) + raise NotImplementedError, "#{self.class.name}#delete_token is not implemented" + end + end + end + end + end +end diff --git a/lib/mcp/auth/server/state_registry.rb b/lib/mcp/auth/server/state_registry.rb deleted file mode 100644 index fb4e9bc..0000000 --- a/lib/mcp/auth/server/state_registry.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module MCP - module Auth - module Server - class StateRegistry - def create(state_id, state) - raise NotImplementedError, "Subclasses must implement" - end - - def find_by_id(state_id) - raise NotImplementedError, "Subclasses must implement" - end - end - - class InMemoryStateRegistry < StateRegistry - def initialize - super - @states = {} - end - - def create(state_id, state) - raise ArgumentError, "State with id '#{state_id}' already exists" if @states.key?(state_id) - - @states[state_id] = state - end - - def find_by_id(state_id) - @states[state_id] - end - end - end - end -end diff --git a/lib/mcp/serialization_utils.rb b/lib/mcp/serialization_utils.rb index 72ec15a..d6b5c68 100644 --- a/lib/mcp/serialization_utils.rb +++ b/lib/mcp/serialization_utils.rb @@ -9,5 +9,11 @@ def to_h(obj) hash[key] = value unless value.nil? end end + + def stringify_keys(h) + h.each_with_object({}) do |(key, value), memo| + memo[key.to_s] = value + end + end end end From 11fdc9ce82191d16d11f50804747eca1c9b2dd7e Mon Sep 17 00:00:00 2001 From: Mazine Mrini Date: Tue, 3 Jun 2025 14:29:02 -0400 Subject: [PATCH 6/6] Adds token handler logic tweaks to instantiation --- lib/mcp.rb | 2 + lib/mcp/auth/errors.rb | 6 + lib/mcp/auth/models.rb | 38 ++++- lib/mcp/auth/server/handlers.rb | 21 +++ .../server/handlers/authorization_handler.rb | 3 +- .../auth/server/handlers/metadata_handler.rb | 6 +- .../server/handlers/registration_handler.rb | 6 +- lib/mcp/auth/server/handlers/token_handler.rb | 146 ++++++++++++++++++ lib/mcp/auth/server/provider.rb | 37 ++++- .../mcp_authorization_server_provider.rb | 81 ++++++++-- .../server/registries/in_memory_registry.rb | 4 +- 11 files changed, 315 insertions(+), 35 deletions(-) create mode 100644 lib/mcp/auth/server/handlers.rb create mode 100644 lib/mcp/auth/server/handlers/token_handler.rb diff --git a/lib/mcp.rb b/lib/mcp.rb index cc87711..ab23890 100644 --- a/lib/mcp.rb +++ b/lib/mcp.rb @@ -31,10 +31,12 @@ require_relative "mcp/auth/server/registries/in_memory_registry" require_relative "mcp/auth/server/request_parser" require_relative "mcp/auth/server/providers/mcp_authorization_server_provider" +require_relative "mcp/auth/server/handlers" require_relative "mcp/auth/server/handlers/metadata_handler" require_relative "mcp/auth/server/handlers/registration_handler" require_relative "mcp/auth/server/handlers/authorization_handler" require_relative "mcp/auth/server/handlers/callback_handler" +require_relative "mcp/auth/server/handlers/token_handler" module MCP class << self diff --git a/lib/mcp/auth/errors.rb b/lib/mcp/auth/errors.rb index e7ea49f..5e9385f 100644 --- a/lib/mcp/auth/errors.rb +++ b/lib/mcp/auth/errors.rb @@ -25,6 +25,8 @@ def initialize(error_code:, message: nil) end end + class ClientAuthenticationError < StandardError; end + class AuthorizationError < StandardError INVALID_REQUEST = "invalid_request" UNAUTHORIZED_CLIENT = "unauthorized_client" @@ -45,6 +47,10 @@ class << self def invalid_request(message) AuthorizationError.new(error_code: INVALID_REQUEST, message:) end + + def invalid_grant(message) + AuthorizationError.new(error_code: INVALID_GRANT, message:) + end end end diff --git a/lib/mcp/auth/models.rb b/lib/mcp/auth/models.rb index dbc35a2..9e6b594 100644 --- a/lib/mcp/auth/models.rb +++ b/lib/mcp/auth/models.rb @@ -125,6 +125,10 @@ def validate_scopes!(requested_scopes) end end + def valid_grant_type?(grant_type) + @grant_types.include?(grant_type) + end + def valid_redirect_uri?(redirect_uri) @redirect_uris.include?(redirect_uri) end @@ -155,6 +159,26 @@ def initialize( @client_id_issued_at = client_id_issued_at @client_secret_expires_at = client_secret_expires_at end + + def authenticate!(request_client_id:, request_client_secret: nil) + raise Errors::ClientAuthenticationError, "invalid client_id" if @client_id != request_client_id + if @client_secret.nil? + return + end + + raise Errors::ClientAuthenticationError, "client_secret mismatch" if @client_secret != request_client_secret + raise Errors::ClientAuthenticationError, "client_secret has expired" if secret_expired? + end + + private + + def secret_expired? + if @client_secret_expires_at.nil? + return false + end + + @client_secret_expires_at < Time.now.to_i + end end # Represents OAuth 2.0 Authorization Server Metadata as defined in RFC 8414. @@ -289,12 +313,12 @@ class << self DEFAULT_REGISTRATION_PATH = "/register" DEFAULT_TOKEN_PATH = "/token" - def from_settings(auth_settings, **kwargs) + def with_defaults(issuer_url:, client_registration_options:, **kwargs) metadata = OAuthMetadata.new( - issuer: auth_settings.issuer_url, - authorization_endpoint: auth_settings.issuer_url + DEFAULT_AUTHORIZE_PATH, - token_endpoint: auth_settings.issuer_url + DEFAULT_TOKEN_PATH, - scopes_supported: auth_settings.client_registration_options.valid_scopes, + issuer: issuer_url, + authorization_endpoint: issuer_url + DEFAULT_AUTHORIZE_PATH, + token_endpoint: issuer_url + DEFAULT_TOKEN_PATH, + scopes_supported: client_registration_options.valid_scopes, response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], token_endpoint_auth_methods_supported: ["client_secret_post"], @@ -302,8 +326,8 @@ def from_settings(auth_settings, **kwargs) **kwargs, ) - if auth_settings.client_registration_options.enabled - metadata.registration_endpoint = auth_settings.issuer_url + DEFAULT_REGISTRATION_PATH + if client_registration_options.enabled + metadata.registration_endpoint = issuer_url + DEFAULT_REGISTRATION_PATH end metadata diff --git a/lib/mcp/auth/server/handlers.rb b/lib/mcp/auth/server/handlers.rb new file mode 100644 index 0000000..b3accb3 --- /dev/null +++ b/lib/mcp/auth/server/handlers.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module MCP + module Auth + module Server + module Handlers + class << self + def create_handlers(auth_server_provider:, request_parser:) + { + oauth_authorization_server: MetadataHandler.new(auth_server_provider:), + register: RegistrationHandler.new(auth_server_provider:, request_parser:), + authorize: AuthorizationHandler.new(auth_server_provider:, request_parser:), + callback: CallbackHandler.new(auth_server_provider:, request_parser:), + token: TokenHandler.new(auth_server_provider:, request_parser:), + } + end + end + end + end + end +end diff --git a/lib/mcp/auth/server/handlers/authorization_handler.rb b/lib/mcp/auth/server/handlers/authorization_handler.rb index d68f140..2327731 100644 --- a/lib/mcp/auth/server/handlers/authorization_handler.rb +++ b/lib/mcp/auth/server/handlers/authorization_handler.rb @@ -87,6 +87,7 @@ def handle(request) client_info.validate_scopes!(scopes) auth_params = AuthorizationParams.new( + client_id: auth_request.client_id, state: auth_request.state, scopes:, code_challenge: auth_request.code_challenge, @@ -94,7 +95,7 @@ def handle(request) redirect_uri_provided_explicitly: auth_request.redirect_uri_provided?, response_type: auth_request.response_type, ) - location = @auth_server_provider.authorize(client_info:, auth_params:) + location = @auth_server_provider.authorize(auth_params) headers = { "Cache-Control": "no-store", "Location": location, diff --git a/lib/mcp/auth/server/handlers/metadata_handler.rb b/lib/mcp/auth/server/handlers/metadata_handler.rb index d7580d4..eeae34c 100644 --- a/lib/mcp/auth/server/handlers/metadata_handler.rb +++ b/lib/mcp/auth/server/handlers/metadata_handler.rb @@ -9,8 +9,8 @@ module Handlers class MetadataHandler include SerializationUtils - def initialize(oauth_metadata) - @oauth_metadata = oauth_metadata + def initialize(auth_server_provider:) + @auth_server_provider = auth_server_provider end # returns [status, headers, body] @@ -19,7 +19,7 @@ def handle(request) "Cache-Control": "public, max-age=3600", "Content-Type": "application/json", } - [200, headers, to_h(@oauth_metadata)] + [200, headers, to_h(@auth_server_provider.oauth_metadata)] end end end diff --git a/lib/mcp/auth/server/handlers/registration_handler.rb b/lib/mcp/auth/server/handlers/registration_handler.rb index 33a4475..ff5066a 100644 --- a/lib/mcp/auth/server/handlers/registration_handler.rb +++ b/lib/mcp/auth/server/handlers/registration_handler.rb @@ -5,6 +5,7 @@ require_relative "../../../serialization_utils" require_relative "../../errors" require_relative "../../models" +require_relative "../settings" module MCP module Auth @@ -15,11 +16,10 @@ class RegistrationHandler def initialize( auth_server_provider:, - client_registration_options:, request_parser: ) @auth_server_provider = auth_server_provider - @client_registration_options = client_registration_options + @client_registration_options = auth_server_provider.client_registration_options @request_parser = request_parser end @@ -94,7 +94,7 @@ def client_secret_expires_at(client_metadata, issued_at) def scope!(client_metadata) if client_metadata.scope.nil? && @client_registration_options.default_scopes - return client_registration_options.default_scopes.join(" ") + return @client_registration_options.default_scopes.join(" ") end if client_metadata.scope diff --git a/lib/mcp/auth/server/handlers/token_handler.rb b/lib/mcp/auth/server/handlers/token_handler.rb new file mode 100644 index 0000000..a053592 --- /dev/null +++ b/lib/mcp/auth/server/handlers/token_handler.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "digest" +require "base64" +require_relative "../../errors" +require_relative "../../server/provider" +require_relative "../../models" + +module MCP + module Auth + module Server + module Handlers + class BaseRequest + attr_reader :grant_type, :client_id, :client_secret + + def initialize( + grant_type:, + client_id:, + client_secret: nil + ) + @grant_type = grant_type + @client_id = client_id + @client_secret = client_secret + end + end + + class AuthorizationCodeRequest < BaseRequest + attr_reader :code, :code_verifier, :redirect_uri + + def initialize( + code:, + code_verifier:, + redirect_uri: nil, + **base_kwargs + ) + super(**base_kwargs) + + @code = code + @code_verifier = code_verifier + @redirect_uri = redirect_uri + + if @grant_type != "authorization_code" + raise Errors::AuthorizationError.invalid_request("grant_type must be authorization_code") + end + end + end + + class TokenHandler + def initialize( + auth_server_provider:, + request_parser: + ) + @auth_server_provider = auth_server_provider + @request_parser = request_parser + end + + def handle(request) + params_h = @request_parser.parse_query_params(request) + request = AuthorizationCodeRequest.new(**params_h) + + client_info = @auth_server_provider.get_client(request.client_id) + validate_request_client!(request:, client_info:) + + auth_code = @auth_server_provider.load_authorization_code(request.code) + validate_auth_code!(request:, auth_code:) + validate_pkce!(request:, auth_code:) + tokens = @auth_server_provider.exchange_authorization_code(auth_code) + + [200, {}, tokens] + rescue Errors::ClientAuthenticationError => e + bad_request_error( + error_code: Errors::AuthorizationError::UNAUTHORIZED_CLIENT, + error_description: e.message, + ) + rescue Errors::AuthorizationError => e + bad_request_error( + error_codea: e.error_code, + error_description: e.message, + ) + rescue + bad_request_error( + error_code: Errors::AuthorizationError::SERVER_ERROR, + error_description: "unexpected error", + ) + end + + private + + def validate_request_client!(request:, client_info: nil) + if client_info.nil? + raise Errors::AuthorizationError.invalid_request("invalid client_id") + end + + client_info.authenticate!( + request_client_id: request.client_id, + request_client_secret: request.client_secret, + ) + unless client_info.valid_grant_type?(request.grant_type) + raise Errors::AuthorizationError.new( + error_code: Errors::AuthorizationError::UNSUPPORTED_GRANT_TYPE, + message: "supported grant type are #{client_info.grant_types}", + ) + end + end + + def validate_auth_code!(request:, auth_code:) + if auth_code.nil? || !auth_code.belongs_to_client?(request.client_id) + # If auth code is for another client, pretend it's not there + raise Errors::AuthorizationError.invalid_grant("authorization code does not exist") + end + + if auth_code.expired? + raise Errors::AuthorizationError.invalid_grant("authorization code expired") + end + + # verify redirect_uri doesn't change between /authorize and /tokens + # see https://datatracker.ietf.org/doc/html/rfc6749#section-10.6 + authorize_request_redirect_uri = auth_code.redirect_uri_provided_explicitly ? auth_code.redirect_uri : nil + if request.redirect_uri != authorize_request_redirect_uri + raise Errors::AuthorizationError.invalid_request("redirect_uri did not match the one when creating auth code") + end + end + + def validate_pkce!(request:, auth_code:) + sha256 = Digest::SHA256.digest(request.code_verifier.encode) + request_code_challenge = Base64.urlsafe_encode64(sha256).tr("=", "") + + unless auth_code.code_challenge_match?(request_code_challenge) + # see https://datatracker.ietf.org/doc/html/rfc7636#section-4.6 + raise Errors::AuthorizationError.invalid_grant("incorrect code_verifier") + end + end + + def bad_request_error( + error_code:, + error_description: + ) + body = { error: error_code, error_description: } + + [400, { "Cache-Control": "no-store", "Pragma": "no-cache" }, body] + end + end + end + end + end +end diff --git a/lib/mcp/auth/server/provider.rb b/lib/mcp/auth/server/provider.rb index b7ccc32..4010186 100644 --- a/lib/mcp/auth/server/provider.rb +++ b/lib/mcp/auth/server/provider.rb @@ -6,7 +6,8 @@ module MCP module Auth module Server class AuthorizationParams - attr_accessor :state, + attr_accessor :client_id, + :state, :scopes, :code_challenge, :redirect_uri, @@ -14,6 +15,7 @@ class AuthorizationParams :response_type def initialize( + client_id:, code_challenge:, redirect_uri:, redirect_uri_provided_explicitly:, @@ -21,6 +23,7 @@ def initialize( state: nil, scopes: nil ) + @client_id = client_id @state = state @scopes = scopes @code_challenge = code_challenge @@ -56,6 +59,18 @@ def initialize( @redirect_uri = redirect_uri @redirect_uri_provided_explicitly = redirect_uri_provided_explicitly end + + def belongs_to_client?(client_id) + @client_id == client_id + end + + def expired? + @expires_at < Time.now.to_i + end + + def code_challenge_match?(other) + @code_challenge == other + end end class RefreshToken @@ -97,6 +112,18 @@ def initialize( end module OAuthAuthorizationServerProvider + # Returns the OAuth metadata for this authorization server. + # See https://datatracker.ietf.org/doc/html/rfc8414#section-2 + # + # @return [MCP::Auth::Models::OAuthMetadata] The OAuth metadata for this server. + def oauth_metadata + raise NotImplementedError, "#{self.class.name}#oauth_metadata is not implemented" + end + + def client_registration_options + raise NotImplementedError, "#{self.class.name}#client_registration_options is not implemented" + end + # Retrieves client information by client ID. # Implementors MAY raise NotImplementedError if dynamic client registration is # disabled in ClientRegistrationOptions. @@ -145,11 +172,10 @@ def register_client(client_info) # entropy, and MUST generate an authorization code with at least 128 bits of entropy. # See https://datatracker.ietf.org/doc/html/rfc6749#section-10.10. # - # @param client_info [MCP::Auth::Models::OAuthClientInformationFull] The client requesting authorization. - # @param params [MCP::Auth::Server::AuthorizationParams] The parameters of the authorization request. + # @param auth_params [MCP::Auth::Server::AuthorizationParams] The parameters of the authorization request. # @return [String] A URL to redirect the client to for authorization. # @raise [MCP::Auth::Errors::AuthorizeError] If the authorization request is invalid. - def authorize(client_info:, auth_params:) + def authorize(auth_params) raise NotImplementedError, "#{self.class.name}#authorize is not implemented" end @@ -171,10 +197,9 @@ def authorize_callback(code:, state:) # Loads an AuthorizationCode by its code string. # - # @param client_info [MCP::Auth::Server::OAuthClientInformationFull] The client that requested the authorization code. # @param authorization_code [String] The authorization code string to load. # @return [MCP::Auth::Server::AuthorizationCode, nil] The AuthorizationCode object, or nil if not found. - def load_authorization_code(client_info, authorization_code) + def load_authorization_code(authorization_code) raise NotImplementedError, "#{self.class.name}#load_authorization_code is not implemented" end diff --git a/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb b/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb index 5facb0a..c04ff58 100644 --- a/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb +++ b/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb @@ -4,33 +4,43 @@ require "net/http" require_relative "../../../serialization_utils" require_relative "../provider" +require_relative "../../models" module MCP module Auth module Server module Providers class McpAuthServerSettings - attr_reader :client_id, + attr_reader :issuer_url, + :client_registration_options, + :client_id, :client_secret, :auth_server_scopes, :auth_server_authorization_endpoint, :auth_server_token_endpoint, - :mcp_callback_endpoint + :mcp_callback_endpoint, + :mcp_access_token_expiry_in_s def initialize( + issuer_url:, + client_registration_options:, client_id:, client_secret:, auth_server_scopes:, auth_server_authorization_endpoint:, auth_server_token_endpoint:, - mcp_callback_endpoint: + mcp_callback_endpoint:, + mcp_access_token_expiry_in_s: 3600 ) + @issuer_url = issuer_url + @client_registration_options = client_registration_options @client_id = client_id @client_secret = client_secret @auth_server_scopes = auth_server_scopes @auth_server_authorization_endpoint = auth_server_authorization_endpoint @auth_server_token_endpoint = auth_server_token_endpoint @mcp_callback_endpoint = mcp_callback_endpoint + @mcp_access_token_expiry_in_s = mcp_access_token_expiry_in_s end end @@ -54,6 +64,17 @@ def initialize( @token_registry = token_registry end + def oauth_metadata + Models::OAuthMetadata.with_defaults( + issuer_url: @settings.issuer_url, + client_registration_options: @settings.client_registration_options, + ) + end + + def client_registration_options + @settings.client_registration_options + end + def get_client(client_id) @client_registry.find_client(client_id) end @@ -62,9 +83,9 @@ def register_client(client_info) @client_registry.create_client(client_info) end - def authorize(client_info:, auth_params:) + def authorize(auth_params) state = auth_params.state || SecureRandom.hex(16) - @state_registry.create_state(state, to_h(auth_params)) + @state_registry.create_state(state, auth_params) auth_url = URI(@settings.auth_server_authorization_endpoint) auth_url.query = URI.encode_www_form([ @@ -82,7 +103,7 @@ def authorize_callback(code:, state:) state_data = @state_registry.find_state(state) raise Errors::AuthorizationError.invalid_request("invalid state parameter") if state_data.nil? - access_token = query_access_token!({ + access_token_3p = query_access_token!({ client_id: @settings.client_id, client_secret: @settings.client_secret, code:, @@ -93,22 +114,22 @@ def authorize_callback(code:, state:) mcp_auth_code = "mcp_#{SecureRandom.hex(16)}" auth_code = MCP::Auth::Server::AuthorizationCode.new( code: mcp_auth_code, - client_id: state_data[:client_id], - redirect_uri: state_data[:redirect_uri], - redirect_uri_provided_explicitly: state_data[:redirect_uri_provided_explicitly], + client_id: state_data.client_id, + redirect_uri: state_data.redirect_uri, + redirect_uri_provided_explicitly: state_data.redirect_uri_provided_explicitly, expires_at: Time.now.to_i + FIVE_MINUTES_IN_SECONDS, scopes: ["mcp:user"], - code_challenge: state_data[:code_challenge], + code_challenge: state_data.code_challenge, ) @auth_code_registry.create_auth_code(mcp_auth_code, auth_code) @token_registry.create_token(mcp_auth_code, AccessToken.new( - token: access_token, - client_id: state_data[:client_id], + token: access_token_3p, + client_id: state_data.client_id, scopes: @settings.auth_server_scopes, expires_at: nil, )) - redirect_uri = URI(state_data[:redirect_uri]) + redirect_uri = URI(state_data.redirect_uri) redirect_uri.query = URI.encode_www_form([ ["code", mcp_auth_code], ["state", state], @@ -118,6 +139,40 @@ def authorize_callback(code:, state:) redirect_uri end + def load_authorization_code(authorization_code) + @auth_code_registry.find_auth_code(authorization_code) + end + + def exchange_authorization_code(authorization_code) + if @auth_code_registry.find_auth_code(authorization_code.code).nil? + raise Errors::AuthorizationError.invalid_request("invalid authorization code") + end + + mcp_token = "mcp_#{SecureRandom.hex(32)}" + @token_registry.create_token(mcp_token, AccessToken.new( + token: mcp_token, + client_id: authorization_code.client_id, + scopes: authorization_code.scopes, + expires_at: Time.now.to_i + @settings.mcp_access_token_expiry_in_s, + )) + + access_token_3p = @token_registry.find_token(authorization_code.code) + unless access_token_3p.nil? + @token_registry.create_token("3p:#{mcp_token}", access_token_3p) + @token_registry.delete_token(authorization_code.code) + end + + @auth_code_registry.delete_auth_code(authorization_code.code) + + Models::OAuthToken.new( + access_token: mcp_token, + token_type: "bearer", + expires_in: @settings.mcp_access_token_expiry_in_s, + scope: authorization_code.scopes.join(" "), + refresh_token: "none_for_now", + ) + end + private def query_access_token!(data) diff --git a/lib/mcp/auth/server/registries/in_memory_registry.rb b/lib/mcp/auth/server/registries/in_memory_registry.rb index 8564772..8070569 100644 --- a/lib/mcp/auth/server/registries/in_memory_registry.rb +++ b/lib/mcp/auth/server/registries/in_memory_registry.rb @@ -38,11 +38,11 @@ def create_auth_code(code_id, data) @codes[code_id] = data end - def find_code(code_id) + def find_auth_code(code_id) @codes[code_id] end - def delete_code(code_id) + def delete_auth_code(code_id) @codes.delete(code_id) end