diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a68d69d..39404c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,4 +19,4 @@ jobs: - name: Install dependencies run: bundle install - name: Run test - run: rake test + run: bundle exec rake test diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 380f677..1ccadb4 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -174,18 +174,56 @@ class SMTPUnsupportedCommand < ProtocolError # === \SMTP Authentication # # The Net::SMTP class supports the \SMTP extension for SASL Authentication - # [RFC4954[https://www.rfc-editor.org/rfc/rfc4954.html]]. To use SMTP - # authentication, pass extra arguments to SMTP.start or SMTP#start. + # [RFC4954[https://www.rfc-editor.org/rfc/rfc4954.html]] and the following + # SASL mechanisms: +ANONYMOUS+, +EXTERNAL+, +OAUTHBEARER+, +PLAIN+, + # +SCRAM-SHA-1+, +SCRAM-SHA-256+, and +XOAUTH2+. + # + # To use \SMTP authentication, pass extra arguments to + # SMTP.start or SMTP#start. # # # PLAIN # Net::SMTP.start('your.smtp.server', 25, # user: 'Your Account', secret: 'Your Password', authtype: :plain) + # Net::SMTP.start("your.smtp.server", 25, + # auth: {type: :plain, + # username: "authentication identity", + # password: password, + # authzid: "authorization identity"}) # optional + # + # # SCRAM-SHA-256 + # Net::SMTP.start("your.smtp.server", 25, + # user: "authentication identity", secret: password, + # authtype: :scram_sha_256) + # Net::SMTP.start("your.smtp.server", 25, + # auth: {type: :scram_sha_256, + # username: "authentication identity", + # password: password, + # authzid: "authorization identity"}) # optional + # + # # OAUTHBEARER + # Net::SMTP.start("your.smtp.server", 25, + # auth: {type: :oauthbearer, + # oauth2_token: oauth2_access_token, + # authzid: "authorization identity", # optional + # host: "your.smtp.server", # optional + # port: 25}) # optional # - # Support for other SASL mechanisms—such as +EXTERNAL+, +OAUTHBEARER+, - # +SCRAM-SHA-256+, and +XOAUTH2+—will be added in a future release. + # # XOAUTH2 + # Net::SMTP.start("your.smtp.server", 25, + # user: "username", secret: oauth2_access_token, authtype: :xoauth2) + # Net::SMTP.start("your.smtp.server", 25, + # auth: {type: :xoauth2, + # username: "username", + # oauth2_token: oauth2_token}) # - # The +LOGIN+ and +CRAM-MD5+ mechanisms are still available for backwards - # compatibility, but are deprecated and should be avoided. + # # EXTERNAL + # Net::SMTP.start("your.smtp.server", 587, + # starttls: :always, ssl_context_params: ssl_ctx_params, + # authtype: "external") + # + # +DIGEST-MD5+, +LOGIN+, and +CRAM-MD5+ are still available for backwards + # compatibility, but are deprecated and should be avoided. Using a + # deprecated authentication mechanisms will print a warning. # class SMTP < Protocol VERSION = "0.4.0" @@ -912,14 +950,17 @@ def authenticate(*args, **kwargs, &block) # include +authcid+ for authentication identity, +authzid+ for authorization # identity, +username+ for either "authentication identity" or # "authorization identity" depending on the +mechanism+, and +password+. - def auth(authtype = nil, *args, type: nil, **kwargs, &blk) - type ||= authtype || DEFAULT_AUTH_TYPE - authenticator = Authenticator.auth_class(type).new(self) - if kwargs.empty? - # TODO: remove this, unless it is needed for 2.6/2.7/3.0 compatibility - critical { authenticator.auth(*args, &blk) } - else - critical { authenticator.auth(*args, **kwargs, &blk) } + # + # Keyword arguments that do not apply to the +mechanism+ may be silently + # ignored. + def auth(*args, **kwargs, &blk) + args, kwargs = backward_compatible_auth_args(*args, **kwargs) + critical do + Authenticator::SASLAdapter.new(self).authenticate(*args, **kwargs, &blk) + rescue SMTPServerBusy, SMTPSyntaxError, SMTPFatalError => error + raise SMTPAuthenticationError.new(error.response) + rescue SASL::AuthenticationIncomplete => error + raise error.response.exception_class.new(error.response) end end @@ -940,6 +981,34 @@ def check_auth_args(user, secret, authtype = DEFAULT_AUTH_TYPE) end end + # Temporarily adapt for differences between SMTP and Net::IMAP::SASL: + # + # * Net::IMAP::SASL doesn't handle the `type` keyword. + # * Net::IMAP::SASL adapters don't handle the +secret+ keyword. + # * Net::IMAP::SASL::LoginAdapter and CramMd5Adapter don't accept kwargs! + # + # These are reasonable changes and Net::IMAP::SASL can be updated. + def backward_compatible_auth_args(typearg = nil, *args, + type: nil, secret: nil, **kwargs) + type ||= typearg || DEFAULT_AUTH_TYPE + check_auth_method(type) + if secret + secret_type = type.match?(/\AX?OAUTH/i) ? :oauth2_token : :password + kwargs[secret_type] ||= secret + end + auth_class = Authenticator.auth_class(type) + if type.match?(/\A(?:LOGIN|CRAM[-_]MD5)\z/i) + usernames = [kwargs.delete(:authcid), + kwargs.delete(:username), + kwargs.delete(:user)] + secrets = [kwargs.delete(:password)] + args[0] ||= usernames.compact.first + args[1] ||= secrets.compact.first + check_auth_args(args[0], args[1], type) + end + [[type, *args], kwargs] + end + # # SMTP command dispatcher # @@ -1048,6 +1117,27 @@ def get_response(reqline) recv_response() end + # Returns a successful Response. + # + # Yields continuation data. + # + # This method may raise: + # + # * Net::SMTPAuthenticationError + # * Net::SMTPServerBusy + # * Net::SMTPSyntaxError + # * Net::SMTPFatalError + # * Net::SMTPUnknownError + def send_command_with_continuations(*args) + server_resp = get_response args.join(" ") + while server_resp.continue? + client_resp = yield server_resp.string.strip.split(nil, 2).last + server_resp = get_response client_resp + end + server_resp.success? or raise server_resp.exception_class.new(server_resp) + server_resp + end + private def validate_line(line) diff --git a/lib/net/smtp/auth_sasl_adapter.rb b/lib/net/smtp/auth_sasl_adapter.rb new file mode 100644 index 0000000..b6ccc7c --- /dev/null +++ b/lib/net/smtp/auth_sasl_adapter.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "net/imap" + +module Net + class SMTP + SASL = Net::IMAP::SASL + + class Authenticator + + # Experimental + # + # Initialize with a block that runs a command, yielding for continuations. + class SASLAdapter < SASL::ClientAdapter + include SASL::ProtocolAdapters::SMTP + + RESPONSE_ERRORS = [ + SMTPAuthenticationError, + SMTPServerBusy, + SMTPSyntaxError, + SMTPFatalError, + ].freeze + + def initialize(...) + super + @command_proc ||= client.method(:send_command_with_continuations) + end + + def host; client.address end + def response_errors; RESPONSE_ERRORS end + def sasl_ir_capable?; true end # TODO + def auth_capable?(mechanism); client.auth_capable?(mechanism) end + def drop_connection; client.finish end + def drop_connection!; client.finish end # TODO + end + + end + end +end diff --git a/lib/net/smtp/auth_sasl_compatibility_adapter.rb b/lib/net/smtp/auth_sasl_compatibility_adapter.rb new file mode 100644 index 0000000..81e1200 --- /dev/null +++ b/lib/net/smtp/auth_sasl_compatibility_adapter.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Net + class SMTP + + # Curries arguments to SMTP#auth, using the Authenticator API. + # + # Net::SMTP#authenticate still supports the v0.4.0 Authenticator API, so + # Authenticator subclasses can still be added and used with it. This class + # will be used as the default, when no matching Authenticator subclass + # exists. + class CompatibilityAdapter + def initialize(mechanism) @mechanism = mechanism end + def new(smtp) @smtp = smtp; self end + def auth(*args, **kwargs, &block) + args.pop while args.any? && args.last.nil? + @smtp.auth(@mechanism, *args, **kwargs, &block) + end + end + + Authenticator.auth_classes.default_proc = ->_, mechanism { + CompatibilityAdapter.new(mechanism) + } + + end +end diff --git a/net-smtp.gemspec b/net-smtp.gemspec index dfef600..74dec15 100644 --- a/net-smtp.gemspec +++ b/net-smtp.gemspec @@ -26,4 +26,5 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "net-protocol" + spec.add_dependency "net-imap", ">= 0.4.1" # experimental SASL support end diff --git a/test/net/smtp/test_smtp.rb b/test/net/smtp/test_smtp.rb index f7a38b8..d2fd19a 100644 --- a/test/net/smtp/test_smtp.rb +++ b/test/net/smtp/test_smtp.rb @@ -530,16 +530,6 @@ def test_start_auth_cram_md5 assert_raise Net::SMTPAuthenticationError do Net::SMTP.start('localhost', port, user: 'account', password: 'password', authtype: :cram_md5){} end - - port = fake_server_start(auth: 'CRAM-MD5') - smtp = Net::SMTP.new('localhost', port) - auth_cram_md5 = Net::SMTP::AuthCramMD5.new(smtp) - auth_cram_md5.define_singleton_method(:digest_class) { raise '"openssl" or "digest" library is required' } - Net::SMTP::AuthCramMD5.define_singleton_method(:new) { |_| auth_cram_md5 } - e = assert_raise RuntimeError do - smtp.start(user: 'account', password: 'password', authtype: :cram_md5){} - end - assert_equal('"openssl" or "digest" library is required', e.message) end def test_start_instance