diff --git a/lib/net/imap.rb b/lib/net/imap.rb
index 66f41769..ade8f95e 100644
--- a/lib/net/imap.rb
+++ b/lib/net/imap.rb
@@ -670,8 +670,9 @@ class IMAP < Protocol
"UTF8=ONLY" => "UTF8=ACCEPT",
}.freeze
- autoload :SASL, File.expand_path("imap/sasl", __dir__)
- autoload :StringPrep, File.expand_path("imap/stringprep", __dir__)
+ autoload :SASL, File.expand_path("imap/sasl", __dir__)
+ autoload :SASLAdapter, File.expand_path("imap/sasl_adapter", __dir__)
+ autoload :StringPrep, File.expand_path("imap/stringprep", __dir__)
include MonitorMixin
if defined?(OpenSSL::SSL)
@@ -1142,7 +1143,10 @@ def starttls(**options)
end
# :call-seq:
- # authenticate(mechanism, *, sasl_ir: true, **, &) -> ok_resp
+ # authenticate(mechanism, *,
+ # sasl_ir: true,
+ # registry: Net::IMAP::SASL.authenticators,
+ # **, &) -> ok_resp
#
# Sends an {AUTHENTICATE command [IMAP4rev1 ยง6.2.2]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.2]
# to authenticate the client. If successful, the connection enters the
@@ -2746,6 +2750,10 @@ def start_tls_session
end
end
+ def sasl_adapter
+ SASLAdapter.new(self, &method(:send_command_with_continuations))
+ end
+
#--
# We could get the saslprep method by extending the SASLprep module
# directly. It's done indirectly, so SASLprep can be lazily autoloaded,
diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb
index 62185ba8..c9154dca 100644
--- a/lib/net/imap/sasl.rb
+++ b/lib/net/imap/sasl.rb
@@ -135,6 +135,10 @@ def initialize(response, message = "authentication ended prematurely")
autoload :BidiStringError, sasl_stringprep_rb
sasl_dir = File.expand_path("sasl", __dir__)
+ autoload :AuthenticationExchange, "#{sasl_dir}/authentication_exchange"
+ autoload :ClientAdapter, "#{sasl_dir}/client_adapter"
+ autoload :ProtocolAdapters, "#{sasl_dir}/protocol_adapters"
+
autoload :Authenticators, "#{sasl_dir}/authenticators"
autoload :GS2Header, "#{sasl_dir}/gs2_header"
autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm"
@@ -155,8 +159,10 @@ def initialize(response, message = "authentication ended prematurely")
# Returns the default global SASL::Authenticators instance.
def self.authenticators; @authenticators ||= Authenticators.new end
- # Delegates to ::authenticators. See Authenticators#authenticator.
- def self.authenticator(...) authenticators.authenticator(...) end
+ # Delegates to registry.new See Authenticators#new.
+ def self.authenticator(*args, registry: authenticators, **kwargs, &block)
+ registry.new(*args, **kwargs, &block)
+ end
# Delegates to ::authenticators. See Authenticators#add_authenticator.
def self.add_authenticator(...) authenticators.add_authenticator(...) end
diff --git a/lib/net/imap/sasl/authentication_exchange.rb b/lib/net/imap/sasl/authentication_exchange.rb
new file mode 100644
index 00000000..3276580c
--- /dev/null
+++ b/lib/net/imap/sasl/authentication_exchange.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+module Net
+ class IMAP
+ module SASL
+
+ # This API is *experimental*, and may change.
+ #
+ # TODO: catch exceptions in #process and send #cancel_response.
+ # TODO: raise an error if the command succeeds after being canceled.
+ # TODO: use with more clients, to verify the API can accommodate them.
+ #
+ # Create an AuthenticationExchange from a client adapter and a mechanism
+ # authenticator:
+ # def authenticate(mechanism, ...)
+ # authenticator = SASL.authenticator(mechanism, ...)
+ # SASL::AuthenticationExchange.new(
+ # sasl_adapter, mechanism, authenticator
+ # ).authenticate
+ # end
+ #
+ # private
+ #
+ # def sasl_adapter = MyClientAdapter.new(self, &method(:send_command))
+ #
+ # Or delegate creation of the authenticator to ::build:
+ # def authenticate(...)
+ # SASL::AuthenticationExchange.build(sasl_adapter, ...)
+ # .authenticate
+ # end
+ #
+ # As a convenience, ::authenticate combines ::build and #authenticate:
+ # def authenticate(...)
+ # SASL::AuthenticationExchange.authenticate(sasl_adapter, ...)
+ # end
+ #
+ # Likewise, ClientAdapter#authenticate delegates to #authenticate:
+ # def authenticate(...) = sasl_adapter.authenticate(...)
+ #
+ class AuthenticationExchange
+ # Convenience method for build(...).authenticate
+ def self.authenticate(...) build(...).authenticate end
+
+ # Use +registry+ to override the global Authenticators registry.
+ def self.build(client, mechanism, *args, sasl_ir: true, **kwargs, &block)
+ authenticator = SASL.authenticator(mechanism, *args, **kwargs, &block)
+ new(client, mechanism, authenticator, sasl_ir: sasl_ir)
+ end
+
+ attr_reader :mechanism, :authenticator
+
+ def initialize(client, mechanism, authenticator, sasl_ir: true)
+ @client = client
+ @mechanism = -mechanism.to_s.upcase.tr(?_, ?-)
+ @authenticator = authenticator
+ @sasl_ir = sasl_ir
+ @processed = false
+ end
+
+ # Call #authenticate to execute an authentication exchange for #client
+ # using #authenticator. Authentication failures will raise an
+ # exception. Any exceptions other than those in RESPONSE_ERRORS will
+ # drop the connection.
+ def authenticate
+ client.run_command(mechanism, initial_response) { process _1 }
+ .tap { raise AuthenticationIncomplete, _1 unless done? }
+ rescue *client.response_errors
+ raise # but don't drop the connection
+ rescue
+ client.drop_connection
+ raise
+ rescue Exception # rubocop:disable Lint/RescueException
+ client.drop_connection!
+ raise
+ end
+
+ def send_initial_response?
+ @sasl_ir &&
+ authenticator.respond_to?(:initial_response?) &&
+ authenticator.initial_response? &&
+ client.sasl_ir_capable? &&
+ client.auth_capable?(mechanism)
+ end
+
+ def done?
+ authenticator.respond_to?(:done?) ? authenticator.done? : @processed
+ end
+
+ private
+
+ attr_reader :client
+
+ def initial_response
+ return unless send_initial_response?
+ client.encode_ir authenticator.process nil
+ end
+
+ def process(challenge)
+ client.encode authenticator.process client.decode challenge
+ ensure
+ @processed = true
+ end
+
+ end
+ end
+ end
+end
diff --git a/lib/net/imap/sasl/authenticators.rb b/lib/net/imap/sasl/authenticators.rb
index 09d271c3..34e52041 100644
--- a/lib/net/imap/sasl/authenticators.rb
+++ b/lib/net/imap/sasl/authenticators.rb
@@ -65,7 +65,7 @@ def names; @authenticators.keys end
# lazily loaded from Net::IMAP::SASL::#{name}Authenticator (case is
# preserved and non-alphanumeric characters are removed..
def add_authenticator(name, authenticator = nil)
- key = name.upcase.to_sym
+ key = -name.to_s.upcase.tr(?_, ?-)
authenticator ||= begin
class_name = "#{name.gsub(/[^a-zA-Z0-9]/, "")}Authenticator".to_sym
auth_class = nil
@@ -79,12 +79,12 @@ def add_authenticator(name, authenticator = nil)
# Removes the authenticator registered for +name+
def remove_authenticator(name)
- key = name.upcase.to_sym
+ key = -name.to_s.upcase.tr(?_, ?-)
@authenticators.delete(key)
end
def mechanism?(name)
- key = name.upcase.to_sym
+ key = -name.to_s.upcase.tr(?_, ?-)
@authenticators.key?(key)
end
@@ -105,8 +105,9 @@ def mechanism?(name)
# only. Protocol client users should see refer to their client's
# documentation, e.g. Net::IMAP#authenticate.
def authenticator(mechanism, ...)
- auth = @authenticators.fetch(mechanism.upcase.to_sym) do
- raise ArgumentError, 'unknown auth type - "%s"' % mechanism
+ key = -mechanism.to_s.upcase.tr(?_, ?-)
+ auth = @authenticators.fetch(key) do
+ raise ArgumentError, 'unknown auth type - "%s"' % key
end
auth.respond_to?(:new) ? auth.new(...) : auth.call(...)
end
diff --git a/lib/net/imap/sasl/client_adapter.rb b/lib/net/imap/sasl/client_adapter.rb
new file mode 100644
index 00000000..ea9e993a
--- /dev/null
+++ b/lib/net/imap/sasl/client_adapter.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module Net
+ class IMAP
+ module SASL
+
+ # This API is *experimental*, and may change.
+ #
+ # TODO: use with more clients, to verify the API can accommodate them.
+ #
+ # An abstract base class for implementing a SASL authentication exchange.
+ # Different clients will each have their own adapter subclass, overridden
+ # to match their needs.
+ #
+ # Although the default implementations _may_ be sufficient, subclasses
+ # will probably need to override some methods. Additionally, subclasses
+ # may need to include a protocol adapter mixin, if the default
+ # ProtocolAdapters::Generic isn't sufficient.
+ class ClientAdapter
+ include ProtocolAdapters::Generic
+
+ attr_reader :client, :command_proc
+
+ # +command_proc+ can used to avoid exposing private methods on #client.
+ # It should run a command with the arguments sent to it, yield each
+ # continuation payload, respond to the server with the result of each
+ # yield, and return the result. Non-successful results *MUST* raise an
+ # exception. Exceptions in the block *MUST* cause the command to fail.
+ #
+ # Subclasses that override #run_command may use #command_proc for
+ # other purposes.
+ def initialize(client, &command_proc)
+ @client, @command_proc = client, command_proc
+ end
+
+ # Delegates to AuthenticationExchange.authenticate.
+ def authenticate(...) AuthenticationExchange.authenticate(self, ...) end
+
+ # Do the protocol and server both support an initial response?
+ def sasl_ir_capable?; client.sasl_ir_capable? end
+
+ # Does the server advertise support for the mechanism?
+ def auth_capable?(mechanism); client.auth_capable?(mechanism) end
+
+ # Runs the authenticate command with +mechanism+ and +initial_response+.
+ # When +initial_response+ is nil, an initial response must NOT be sent.
+ #
+ # Yields each continuation payload, responds to the server with the
+ # result of each yield, and returns the result. Non-successful results
+ # *MUST* raise an exception. Exceptions in the block *MUST* cause the
+ # command to fail.
+ #
+ # Subclasses that override this may use #command_proc differently.
+ def run_command(mechanism, initial_response = nil, &block)
+ command_proc or raise Error, "initialize with block or override"
+ args = [command_name, mechanism, initial_response].compact
+ command_proc.call(*args, &block)
+ end
+
+ # Returns an array of server responses errors raised by run_command.
+ # Exceptions in this array won't drop the connection.
+ def response_errors; [] end
+
+ # Drop the connection gracefully.
+ def drop_connection; client.drop_connection end
+
+ # Drop the connection abruptly.
+ def drop_connection!; client.drop_connection! end
+ end
+ end
+ end
+end
diff --git a/lib/net/imap/sasl/protocol_adapters.rb b/lib/net/imap/sasl/protocol_adapters.rb
new file mode 100644
index 00000000..519b4596
--- /dev/null
+++ b/lib/net/imap/sasl/protocol_adapters.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Net
+ class IMAP
+ module SASL
+
+ module ProtocolAdapters
+ # This API is experimental, and may change.
+ module Generic
+ def command_name; "AUTHENTICATE" end
+ def service; raise "Implement in subclass or module" end
+ def host; client.host end
+ def port; client.port end
+ def encode_ir(string) string.empty? ? "=" : encode(string) end
+ def encode(string) [string].pack("m0") end
+ def decode(string) string.unpack1("m0") end
+ def cancel_response; "*" end
+ end
+
+ # See RFC-3501 (IMAP4rev1), RFC-4959 (SASL-IR capability),
+ # and RFC-9051 (IMAP4rev2).
+ module IMAP
+ include Generic
+ def service; "imap" end
+ end
+
+ # See RFC-4954 (AUTH capability).
+ module SMTP
+ include Generic
+ def command_name; "AUTH" end
+ def service; "smtp" end
+ end
+
+ # See RFC-5034 (SASL capability).
+ module POP
+ include Generic
+ def command_name; "AUTH" end
+ def service; "pop" end
+ end
+
+ end
+
+ end
+ end
+end
diff --git a/lib/net/imap/sasl_adapter.rb b/lib/net/imap/sasl_adapter.rb
new file mode 100644
index 00000000..7979414e
--- /dev/null
+++ b/lib/net/imap/sasl_adapter.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Net
+ class IMAP
+
+ # Experimental
+ class SASLAdapter < SASL::ClientAdapter
+ include SASL::ProtocolAdapters::IMAP
+
+ RESPONSE_ERRORS = [NoResponseError, BadResponseError, ByeResponseError]
+ .freeze
+
+ def response_errors; RESPONSE_ERRORS end
+ def sasl_ir_capable?; client.capable?("SASL-IR") end
+ def auth_capable?(mechanism); client.auth_capable?(mechanism) end
+ def drop_connection; client.logout! end
+ def drop_connection!; client.disconnect end
+ end
+
+ end
+end