diff --git a/lib/mcp.rb b/lib/mcp.rb index 7eb8870..ab23890 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" @@ -18,6 +19,24 @@ require_relative "mcp/version" require_relative "mcp/configuration" 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/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" +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 new file mode 100644 index 0000000..5e9385f --- /dev/null +++ b/lib/mcp/auth/errors.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module MCP + module Auth + module Errors + class InvalidScopeError < StandardError; end + + 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" + 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 ClientAuthenticationError < StandardError; 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 + + 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 + + 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 new file mode 100644 index 0000000..9e6b594 --- /dev/null +++ b/lib/mcp/auth/models.rb @@ -0,0 +1,339 @@ +# 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_scopes!(requested_scopes) + 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 + 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 + + def multiple_redirect_uris? + @redirect_uris.size > 1 + 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 + + 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. + # 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: nil, + token_endpoint: nil, + 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 + + (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 + + class << self + DEFAULT_AUTHORIZE_PATH = "/authorize" + DEFAULT_REGISTRATION_PATH = "/register" + DEFAULT_TOKEN_PATH = "/token" + + def with_defaults(issuer_url:, client_registration_options:, **kwargs) + metadata = OAuthMetadata.new( + 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"], + code_challenge_methods_supported: ["S256"], + **kwargs, + ) + + if client_registration_options.enabled + metadata.registration_endpoint = issuer_url + DEFAULT_REGISTRATION_PATH + end + + metadata + end + end + end + end + end +end 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 new file mode 100644 index 0000000..2327731 --- /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( + client_id: auth_request.client_id, + 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(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/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/metadata_handler.rb b/lib/mcp/auth/server/handlers/metadata_handler.rb new file mode 100644 index 0000000..eeae34c --- /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(auth_server_provider:) + @auth_server_provider = auth_server_provider + end + + # returns [status, headers, body] + def handle(request) + headers = { + "Cache-Control": "public, max-age=3600", + "Content-Type": "application/json", + } + [200, headers, to_h(@auth_server_provider.oauth_metadata)] + 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 new file mode 100644 index 0000000..ff5066a --- /dev/null +++ b/lib/mcp/auth/server/handlers/registration_handler.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require "securerandom" +require "time" +require_relative "../../../serialization_utils" +require_relative "../../errors" +require_relative "../../models" +require_relative "../settings" + +module MCP + module Auth + module Server + module Handlers + class RegistrationHandler + include SerializationUtils + + def initialize( + auth_server_provider:, + request_parser: + ) + @auth_server_provider = auth_server_provider + @client_registration_options = auth_server_provider.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/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 new file mode 100644 index 0000000..4010186 --- /dev/null +++ b/lib/mcp/auth/server/provider.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +require_relative "../models" + +module MCP + module Auth + module Server + class AuthorizationParams + attr_accessor :client_id, + :state, + :scopes, + :code_challenge, + :redirect_uri, + :redirect_uri_provided_explicitly, + :response_type + + def initialize( + client_id:, + code_challenge:, + redirect_uri:, + redirect_uri_provided_explicitly:, + response_type:, + state: nil, + scopes: nil + ) + @client_id = client_id + @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 + 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 + + 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 + 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 + + 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. + # + # @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 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(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 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(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/providers/mcp_authorization_server_provider.rb b/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb new file mode 100644 index 0000000..c04ff58 --- /dev/null +++ b/lib/mcp/auth/server/providers/mcp_authorization_server_provider.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require "securerandom" +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 :issuer_url, + :client_registration_options, + :client_id, + :client_secret, + :auth_server_scopes, + :auth_server_authorization_endpoint, + :auth_server_token_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_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 + + class McpAuthorizationServerProvider + include OAuthAuthorizationServerProvider + include SerializationUtils + + FIVE_MINUTES_IN_SECONDS = 300 + + def initialize( + auth_server_settings:, + client_registry:, + state_registry:, + auth_code_registry:, + token_registry: + ) + @settings = auth_server_settings + @client_registry = client_registry + @state_registry = state_registry + @auth_code_registry = auth_code_registry + @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 + + def register_client(client_info) + @client_registry.create_client(client_info) + end + + def authorize(auth_params) + state = auth_params.state || SecureRandom.hex(16) + @state_registry.create_state(state, 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 + + 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_3p = 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_3p, + 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 + + 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) + 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 + 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..8070569 --- /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_auth_code(code_id) + @codes[code_id] + end + + def delete_auth_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/request_parser.rb b/lib/mcp/auth/server/request_parser.rb new file mode 100644 index 0000000..7b105de --- /dev/null +++ b/lib/mcp/auth/server/request_parser.rb @@ -0,0 +1,33 @@ +# 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, "#{self.class.name}#parse_body is not implemented" + end + + # 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_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 + end +end diff --git a/lib/mcp/auth/server/settings.rb b/lib/mcp/auth/server/settings.rb new file mode 100644 index 0000000..c12b35d --- /dev/null +++ b/lib/mcp/auth/server/settings.rb @@ -0,0 +1,77 @@ +# 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, + :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 + + 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 + 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..d6b5c68 --- /dev/null +++ b/lib/mcp/serialization_utils.rb @@ -0,0 +1,19 @@ +# 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 = obj.instance_variable_get(var) + 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