Skip to content

Commit

Permalink
Use net-imap's SASL implementation 🚧[WIP]🚧
Browse files Browse the repository at this point in the history
This commit adds the `net-imap` as a default fallback for mechanisms
that haven't otherwise been added.  In this commit, the original
implementation is still used by `#authenticate` for the `PLAIN`,
`LOGIN`, and `CRAM-MD5` mechanisms.  Every other mechanism supported by
`net-imap` v0.4.0 is added here:
* `ANONYMOUS`
* `DIGEST-MD5` _(deprecated)_
* `EXTERNAL`
* `OAUTHBEARER`
* `SCRAM-SHA-1` and `SCRAM-SHA-256`
* `XOAUTH`

**TODO:** Ideally, `net-smtp` and `net-imap` should both depend on a
shared `sasl` or `net-sasl` gem, rather than keep the SASL
implementation inside one or the other.  See
ruby/net-imap#23.
  • Loading branch information
nevans committed Oct 14, 2023
1 parent 64e9b56 commit dcfc541
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 15 deletions.
59 changes: 54 additions & 5 deletions lib/net/smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ class SMTPUnsupportedCommand < ProtocolError
#
# The Net::SMTP class supports the \SMTP extension for SASL Authentication
# [RFC4954[https://www.rfc-editor.org/rfc/rfc4954.html]] and the following
# SASL mechanisms: +PLAIN+, +LOGIN+ _(deprecated)_, and +CRAM-MD5+
# _(deprecated)_.
# 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.
Expand All @@ -190,10 +190,38 @@ class SMTPUnsupportedCommand < ProtocolError
# password: password,
# authzid: "authorization identity"}) # optional
#
# Support for other SASL mechanisms—such as +EXTERNAL+, +OAUTHBEARER+,
# +SCRAM-SHA-256+, and +XOAUTH2+—will be added in a future release.
# # 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
#
# # 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})
#
# # EXTERNAL
# Net::SMTP.start("your.smtp.server", 587,
# starttls: :always, ssl_context_params: ssl_ctx_params,
# authtype: "external")
#
# The +LOGIN+ and +CRAM-MD5+ mechanisms are still available for backwards
# +DIGEST-MD5+, +LOGIN+, and +CRAM-MD5+ are still available for backwards
# compatibility, but are deprecated and should be avoided.
#
class SMTP < Protocol
Expand Down Expand Up @@ -1038,6 +1066,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)
Expand Down
72 changes: 72 additions & 0 deletions lib/net/smtp/auth_sasl_client_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

require "net/imap"

module Net
class SMTP
SASL = Net::IMAP::SASL

# Experimental
#
# Initialize with a block that runs a command, yielding for continuations.
class SASLClientAdapter < 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

# Translates +user+ to +username+ and +type+ kwarg to the first arg.
def authenticate(typearg = nil, *args,
type: nil, user: nil,
**kwargs, &block)
kwargs[:username] ||= user if user
type ||= typearg || DEFAULT_AUTH_TYPE
args, kwargs = backward_compatible_auth_args(type, *args, **kwargs)
super(type, *args, **kwargs)
rescue SMTPServerBusy, SMTPSyntaxError, SMTPFatalError => error
raise SMTPAuthenticationError.new(error.response)
rescue SASL::AuthenticationIncomplete => error
raise error.response.exception_class.new(error.response)
end

def host; client.address end
def response_errors; RESPONSE_ERRORS end
def sasl_ir_capable?; true end
def drop_connection; client.finish end
def drop_connection!; client.finish end

private

# Temporarily adapt for differences between SMTP and Net::IMAP::SASL:
#
# * 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(type, *args, secret: nil, **kwargs)
if secret
secret_type = type.match?(/\AX?OAUTH/i) ? :oauth2_token : :password
kwargs[secret_type] ||= secret
end
if type.match?(/\A(?:LOGIN|CRAM[-_]MD5)\z/i)
usernames = [kwargs.delete(:authcid), kwargs.delete(:username)]
secrets = [kwargs.delete(:password)]
args[0] ||= usernames.compact.first
args[1] ||= secrets.compact.first
end
[args, kwargs]
end

end

end
end
19 changes: 19 additions & 0 deletions lib/net/smtp/auth_sasl_compatibility_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

module Net
class SMTP

# Curries arguments to SASLAdapter.authenticate.
class AuthSASLCompatibilityAdapter
def initialize(mechanism) @mechanism = mechanism end
def check_args(...) SASL.authenticator(...) end
def new(smtp) @sasl_adapter = SASLClientAdapter.new(smtp); self end
def auth(...) @sasl_adapter.authenticate(@mechanism, ...) end
end

Authenticator.auth_classes.default_proc = ->hash, mechanism {
hash[mechanism] = AuthSASLCompatibilityAdapter.new(mechanism)
}

end
end
1 change: 1 addition & 0 deletions net-smtp.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 0 additions & 10 deletions test/net/smtp/test_smtp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit dcfc541

Please sign in to comment.