Skip to content

Commit baf9035

Browse files
committed
Add forward-compatible API for SASL mechanisms
The current #start and #authenticate API can't fully support every SASL mechanism. Some SASL mechanisms do not require a +username+ (OAUTHBEARER, EXTERNAL, ANONYMOUS) or a +secret+ (EXTERNAL, ANONYMOUS). Many SASL mechanisms will need to take extra arguments (e.g: `authzid` for many mechanisms, `warn_deprecations` for deprecated mechanisms, `min_iterations` for SCRAM-*, `anonymous_message` for ANONYMOUS), and so on. And, although it is convenient to use +username+ as an alias for +authcid+ or +authzid+ and +secret+ as an alias for +password+ or +oauth2_token+, it can also be useful to have keyword parameters that keep stable semantics across many different mechanisms. A SASL-compatible API must first find the authenticator for the mechanism and then delegate any arbitrary parameters to that authenticator. Practically, that means that the mechanism name must either be the first positional parameter or a keyword parameter, and then every other parameter can be forwarded. `Net::SMTP#auth` does this. Also, an `auth` keyword parameter has been added to `Net::SMTP#start`, allowing `start` to pass any arbitrary keyword parameters into `#auth`. For backward compatibility, when one of the existing Authenticator classes is selected, it converts keyword args to positional args. Also for backward compatibility, `Net::SMTP#authenticate` keeps its v0.4.0 implementation.
1 parent 36e5151 commit baf9035

File tree

2 files changed

+136
-20
lines changed

2 files changed

+136
-20
lines changed

lib/net/smtp.rb

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,29 @@ class SMTPUnsupportedCommand < ProtocolError
179179
# # PLAIN
180180
# Net::SMTP.start('your.smtp.server', 25,
181181
# user: 'Your Account', secret: 'Your Password', authtype: :plain)
182+
# Net::SMTP.start("your.smtp.server", 25,
183+
# auth: {type: :plain,
184+
# username: "authentication identity",
185+
# password: password})
186+
#
182187
# # LOGIN
183188
# Net::SMTP.start('your.smtp.server', 25,
184189
# user: 'Your Account', secret: 'Your Password', authtype: :login)
190+
# Net::SMTP.start("your.smtp.server", 25,
191+
# auth: {type: :login,
192+
# username: "authentication identity",
193+
# password: password})
185194
#
186195
# # CRAM MD5
187196
# Net::SMTP.start('your.smtp.server', 25,
188197
# user: 'Your Account', secret: 'Your Password', authtype: :cram_md5)
198+
# Net::SMTP.start("your.smtp.server", 25,
199+
# auth: {type: :cram_md5,
200+
# username: 'Your Account',
201+
# password: 'Your Password'})
202+
#
203+
# +LOGIN+, and +CRAM-MD5+ are still available for backwards compatibility, but
204+
# are deprecated and should be avoided.
189205
#
190206
class SMTP < Protocol
191207
VERSION = "0.4.0"
@@ -452,6 +468,7 @@ def debug_output=(arg)
452468

453469
#
454470
# :call-seq:
471+
# start(address, port = nil, helo: 'localhost', auth: nil, tls: false, starttls: :auto, tls_verify: true, tls_hostname: nil, ssl_context_params: nil) { |smtp| ... }
455472
# start(address, port = nil, helo: 'localhost', user: nil, secret: nil, authtype: nil, tls: false, starttls: :auto, tls_verify: true, tls_hostname: nil, ssl_context_params: nil) { |smtp| ... }
456473
# start(address, port = nil, helo = 'localhost', user = nil, secret = nil, authtype = nil) { |smtp| ... }
457474
#
@@ -517,16 +534,17 @@ def debug_output=(arg)
517534
# * IOError
518535
#
519536
def SMTP.start(address, port = nil, *args, helo: nil,
520-
user: nil, secret: nil, password: nil, authtype: nil,
537+
user: nil, username: nil, secret: nil, password: nil,
538+
authtype: nil, auth: nil,
521539
tls: false, starttls: :auto,
522540
tls_verify: true, tls_hostname: nil, ssl_context_params: nil,
523541
&block)
524542
raise ArgumentError, "wrong number of arguments (given #{args.size + 2}, expected 1..6)" if args.size > 4
525543
helo ||= args[0] || 'localhost'
526-
user ||= args[1]
544+
user ||= username || args[1]
527545
secret ||= password || args[2]
528546
authtype ||= args[3]
529-
new(address, port, tls: tls, starttls: starttls, tls_verify: tls_verify, tls_hostname: tls_hostname, ssl_context_params: ssl_context_params).start(helo: helo, user: user, secret: secret, authtype: authtype, &block)
547+
new(address, port, tls: tls, starttls: starttls, tls_verify: tls_verify, tls_hostname: tls_hostname, ssl_context_params: ssl_context_params).start(helo: helo, user: user, secret: secret, authtype: authtype, auth: auth, &block)
530548
end
531549

532550
# +true+ if the SMTP session has been started.
@@ -538,6 +556,7 @@ def started?
538556
# :call-seq:
539557
# start(helo: 'localhost', user: nil, secret: nil, authtype: nil) { |smtp| ... }
540558
# start(helo = 'localhost', user = nil, secret = nil, authtype = nil) { |smtp| ... }
559+
# start(helo = 'localhost', auth: {type: nil, **auth_kwargs}) { |smtp| ... }
541560
#
542561
# Opens a TCP connection and starts the SMTP session.
543562
#
@@ -546,11 +565,10 @@ def started?
546565
# +helo+ is the _HELO_ _domain_ that you'll dispatch mails from; see
547566
# the discussion in the overview notes.
548567
#
549-
# If both of +user+ and +secret+ are given, SMTP authentication
550-
# will be attempted using the AUTH command. +authtype+ specifies
551-
# the type of authentication to attempt; it must be one of
552-
# :login, :plain, and :cram_md5. See the notes on SMTP Authentication
553-
# in the overview.
568+
# If either +auth+ or +user+ are given, SMTP authentication will be
569+
# attempted using the AUTH command. +authtype+ specifies the type of
570+
# authentication to attempt; it must be one of :login, :plain, and
571+
# :cram_md5. See the notes on SMTP Authentication in the overview.
554572
#
555573
# === Block Usage
556574
#
@@ -589,12 +607,15 @@ def started?
589607
# * Net::ReadTimeout
590608
# * IOError
591609
#
592-
def start(*args, helo: nil, user: nil, secret: nil, password: nil, authtype: nil)
610+
def start(*args, helo: nil,
611+
user: nil, username: nil, secret: nil, password: nil,
612+
authtype: nil, auth: nil)
593613
raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0..4)" if args.size > 4
594614
helo ||= args[0] || 'localhost'
595-
user ||= args[1]
596-
secret ||= password || args[2]
597-
authtype ||= args[3]
615+
auth = merge_auth_params(user || username || args[1],
616+
secret || password || args[2],
617+
authtype || args[3],
618+
auth)
598619
if defined?(OpenSSL::VERSION)
599620
ssl_context_params = @ssl_context_params || {}
600621
unless ssl_context_params.has_key?(:verify_mode)
@@ -609,13 +630,13 @@ def start(*args, helo: nil, user: nil, secret: nil, password: nil, authtype: nil
609630
end
610631
if block_given?
611632
begin
612-
do_start helo, user, secret, authtype
633+
do_start helo, **auth
613634
return yield(self)
614635
ensure
615636
do_finish
616637
end
617638
else
618-
do_start helo, user, secret, authtype
639+
do_start helo, **auth
619640
return self
620641
end
621642
end
@@ -633,12 +654,8 @@ def tcp_socket(address, port)
633654
TCPSocket.open address, port
634655
end
635656

636-
def do_start(helo_domain, user, secret, authtype)
657+
def do_start(helo_domain, **authopts)
637658
raise IOError, 'SMTP session already started' if @started
638-
if user or secret
639-
check_auth_method(authtype || DEFAULT_AUTH_TYPE)
640-
check_auth_args user, secret
641-
end
642659
s = Timeout.timeout(@open_timeout, Net::OpenTimeout) do
643660
tcp_socket(@address, @port)
644661
end
@@ -655,7 +672,7 @@ def do_start(helo_domain, user, secret, authtype)
655672
# helo response may be different after STARTTLS
656673
do_helo helo_domain
657674
end
658-
authenticate user, secret, (authtype || DEFAULT_AUTH_TYPE) if user
675+
auth(**authopts) if authopts.any?
659676
@started = true
660677
ensure
661678
unless @started
@@ -833,13 +850,39 @@ def open_message_stream(from_addr, *to_addrs, &block) # :yield: stream
833850

834851
DEFAULT_AUTH_TYPE = :plain
835852

853+
# Deprecated: use #auth instead.
836854
def authenticate(user, secret, authtype = DEFAULT_AUTH_TYPE)
855+
# warn "DEPRECATED: use Net::SMTP#auth instead"
837856
check_auth_method authtype
838857
check_auth_args user, secret
839858
authenticator = Authenticator.auth_class(authtype).new(self)
840859
critical { authenticator.auth(user, secret) }
841860
end
842861

862+
# call-seq:
863+
# auth(mechanism, ...)
864+
# auth(type: mechanism, **kwargs, &block)
865+
#
866+
# All arguments besides +mechanism+ are forwarded directly to the
867+
# authenticator. Alternatively, +mechanism+ can be provided by the +type+
868+
# keyword parameter. Positional parameters cannot be used with +type+.
869+
#
870+
# Different authenticators take different options, but common options
871+
# include +authcid+ for authentication identity, +authzid+ for authorization
872+
# identity, +username+ for either "authentication identity" or
873+
# "authorization identity" depending on the +mechanism+, and +password+.
874+
def auth(*args, **kwargs, &blk)
875+
args, kwargs = backward_compatible_auth_args(*args, **kwargs)
876+
authtype, *args = args
877+
authenticator = Authenticator.auth_class(authtype).new(self)
878+
if kwargs.empty?
879+
# TODO: remove this, unless it is needed for 2.6/2.7/3.0 compatibility
880+
critical { authenticator.auth(*args, &blk) }
881+
else
882+
critical { authenticator.auth(*args, **kwargs, &blk) }
883+
end
884+
end
885+
843886
private
844887

845888
def check_auth_method(type)
@@ -857,6 +900,45 @@ def check_auth_args(user, secret, authtype = DEFAULT_AUTH_TYPE)
857900
end
858901
end
859902

903+
# Convert the original +user+, +secret+, +authtype+ with +auth+, and checks
904+
# the arguments.
905+
def merge_auth_params(user, secret, authtype, auth)
906+
auth = Hash.try_convert(auth) || {}
907+
if user || secret || authtype
908+
args = { type: authtype || DEFAULT_AUTH_TYPE,
909+
username: user, secret: secret }
910+
auth = args.merge(auth)
911+
check_auth_method(auth[:type])
912+
check_auth_args(auth[:authcid] || auth[:username],
913+
auth[:password] || auth[:secret],
914+
auth[:type])
915+
elsif auth.any?
916+
check_auth_method(auth[:type] || DEFAULT_AUTH_TYPE)
917+
# check_auth_args may not be valid, depending on the authtype.
918+
end
919+
auth
920+
end
921+
922+
# Convert +type+, +username+, +secret+ (etc) kwargs to positional args, for
923+
# compatibility with existing authenticators.
924+
def backward_compatible_auth_args(_type = nil, *args, type: nil,
925+
username: nil, authcid: nil,
926+
secret: nil, password: nil,
927+
**kwargs)
928+
type && _type and
929+
raise ArgumentError, 'conflict between "type" keyword argument ' \
930+
'and positional argument'
931+
type ||= _type || DEFAULT_AUTH_TYPE
932+
check_auth_method(type)
933+
auth_class = Authenticator.auth_class(type)
934+
if auth_class.is_a?(Class) && auth_class <= Authenticator
935+
args[0] ||= authcid || username
936+
args[1] ||= password || secret
937+
check_auth_args(args[0], args[1], type)
938+
end
939+
[[type, *args], kwargs]
940+
end
941+
860942
#
861943
# SMTP command dispatcher
862944
#

test/net/smtp/test_smtp.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,21 @@ def test_auth_plain
110110
smtp = Net::SMTP.start 'localhost', server.port
111111
assert smtp.authenticate("account", "password", :plain).success?
112112
assert_equal "AUTH PLAIN AGFjY291bnQAcGFzc3dvcmQ=\r\n", server.commands.last
113+
114+
server = FakeServer.start(auth: 'plain')
115+
smtp = Net::SMTP.start 'localhost', server.port
116+
assert smtp.auth("PLAIN", "account", "password").success?
117+
assert_equal "AUTH PLAIN AGFjY291bnQAcGFzc3dvcmQ=\r\n", server.commands.last
118+
119+
server = FakeServer.start(auth: 'plain')
120+
smtp = Net::SMTP.start 'localhost', server.port
121+
assert smtp.auth(type: "PLAIN", username: "account", secret: "password").success?
122+
assert_equal "AUTH PLAIN AGFjY291bnQAcGFzc3dvcmQ=\r\n", server.commands.last
123+
124+
server = FakeServer.start(auth: 'plain')
125+
smtp = Net::SMTP.start 'localhost', server.port
126+
assert smtp.auth("PLAIN", username: "account", password: "password").success?
127+
assert_equal "AUTH PLAIN AGFjY291bnQAcGFzc3dvcmQ=\r\n", server.commands.last
113128
end
114129

115130
def test_unsucessful_auth_plain
@@ -120,10 +135,20 @@ def test_unsucessful_auth_plain
120135
assert_equal "535", err.response.status
121136
end
122137

138+
def test_auth_cram_md5
139+
server = FakeServer.start(auth: 'CRAM-MD5')
140+
smtp = Net::SMTP.start 'localhost', server.port
141+
assert smtp.auth(:cram_md5, "account", password: "password").success?
142+
end
143+
123144
def test_auth_login
124145
server = FakeServer.start(auth: 'login')
125146
smtp = Net::SMTP.start 'localhost', server.port
126147
assert smtp.authenticate("account", "password", :login).success?
148+
149+
server = FakeServer.start(auth: 'login')
150+
smtp = Net::SMTP.start 'localhost', server.port
151+
assert smtp.auth("LOGIN", username: "account", secret: "password").success?
127152
end
128153

129154
def test_unsucessful_auth_login
@@ -455,6 +480,15 @@ def test_start_auth_plain
455480
port = fake_server_start(auth: 'plain')
456481
Net::SMTP.start('localhost', port, user: 'account', password: 'password', authtype: :plain){}
457482

483+
port = fake_server_start(auth: 'plain')
484+
Net::SMTP.start('localhost', port, authtype: "PLAIN",
485+
auth: {username: 'account', password: 'password'}){}
486+
487+
port = fake_server_start(auth: 'plain')
488+
Net::SMTP.start('localhost', port, auth: {username: 'account',
489+
password: 'password',
490+
type: :plain}){}
491+
458492
port = fake_server_start(auth: 'plain')
459493
assert_raise Net::SMTPAuthenticationError do
460494
Net::SMTP.start('localhost', port, user: 'account', password: 'invalid', authtype: :plain){}

0 commit comments

Comments
 (0)