Skip to content

Commit 6bb82aa

Browse files
committed
Use net-imap's SASL implementation 🚧[WIP]🚧
This commit converts `#authenticate` to use `net-imap` as a generic fallback for mechanisms that haven't otherwise been added (as subclasses of `Authenticator`). 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. **TODO:** since we already know the authenticator arguments up-front, we can validate authenticator arguments by simply creating the authenticator object and rely on the its initializer to raise ArgumentError for missing args.
1 parent cbe0282 commit 6bb82aa

File tree

6 files changed

+164
-55
lines changed

6 files changed

+164
-55
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ jobs:
1919
- name: Install dependencies
2020
run: bundle install
2121
- name: Run test
22-
run: rake test
22+
run: bundle exec rake test

lib/net/smtp.rb

Lines changed: 97 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,11 @@ class SMTPUnsupportedCommand < ProtocolError
171171
#
172172
# === SMTP Authentication
173173
#
174-
# The Net::SMTP class supports three authentication schemes;
175-
# PLAIN, LOGIN and CRAM MD5. (SMTP Authentication: [RFC2554])
174+
# The Net::SMTP class supports several authentication schemes;
175+
# ({SMTP Authentication: [RFC4956]}[https://www.rfc-editor.org/rfc/rfc4954.html])
176+
# +ANONYMOUS+, +EXTERNAL+, +OAUTHBEARER+, +PLAIN+, +SCRAM-SHA-1+,
177+
# +SCRAM-SHA-256+, and +XOAUTH2+.
178+
#
176179
# To use SMTP authentication, pass extra arguments to
177180
# SMTP.start or SMTP#start.
178181
#
@@ -182,26 +185,43 @@ class SMTPUnsupportedCommand < ProtocolError
182185
# Net::SMTP.start("your.smtp.server", 25,
183186
# auth: {type: :plain,
184187
# username: "authentication identity",
185-
# password: password})
188+
# password: password,
189+
# authzid: "authorization identity"}) # optional
186190
#
187-
# # LOGIN
188-
# Net::SMTP.start('your.smtp.server', 25,
189-
# user: 'Your Account', secret: 'Your Password', authtype: :login)
191+
# # SCRAM-SHA-256
190192
# Net::SMTP.start("your.smtp.server", 25,
191-
# auth: {type: :login,
193+
# user: "authentication identity", secret: password,
194+
# authtype: :scram_sha_256)
195+
# Net::SMTP.start("your.smtp.server", 25,
196+
# auth: {type: :scram_sha_256,
192197
# username: "authentication identity",
193-
# password: password})
198+
# password: password,
199+
# authzid: "authorization identity"}) # optional
194200
#
195-
# # CRAM MD5
196-
# Net::SMTP.start('your.smtp.server', 25,
197-
# user: 'Your Account', secret: 'Your Password', authtype: :cram_md5)
201+
# # OAUTHBEARER
202+
# Net::SMTP.start("your.smtp.server", 25,
203+
# auth: {type: :oauthbearer,
204+
# oauth2_token: oauth2_access_token,
205+
# authzid: "authorization identity", # optional
206+
# host: "your.smtp.server", # optional
207+
# port: 25}) # optional
208+
#
209+
# # XOAUTH2
210+
# Net::SMTP.start("your.smtp.server", 25,
211+
# user: "username", secret: oauth2_access_token, authtype: :xoauth2)
198212
# Net::SMTP.start("your.smtp.server", 25,
199-
# auth: {type: :cram_md5,
200-
# username: 'Your Account',
201-
# password: 'Your Password'})
213+
# auth: {type: :xoauth2,
214+
# username: "username",
215+
# oauth2_token: oauth2_token})
202216
#
203-
# +LOGIN+, and +CRAM-MD5+ are still available for backwards compatibility, but
204-
# are deprecated and should be avoided.
217+
# # EXTERNAL
218+
# Net::SMTP.start("your.smtp.server", 587,
219+
# starttls: :always, ssl_context_params: ssl_ctx_params,
220+
# authtype: "external")
221+
#
222+
# +DIGEST-MD5+, +LOGIN+, and +CRAM-MD5+ are still available for backwards
223+
# compatibility, but are deprecated and should be avoided. <em>Using a
224+
# deprecated authentication mechanisms will print a warning.</em>
205225
#
206226
class SMTP < Protocol
207227
VERSION = "0.4.0"
@@ -501,12 +521,6 @@ def debug_output=(arg)
501521
# +helo+ is the _HELO_ _domain_ provided by the client to the
502522
# server (see overview comments); it defaults to 'localhost'.
503523
#
504-
# The remaining arguments are used for SMTP authentication, if required
505-
# or desired. +user+ is the account name; +secret+ is your password
506-
# or other authentication token; and +authtype+ is the authentication
507-
# type, one of :plain, :login, or :cram_md5. See the discussion of
508-
# SMTP Authentication in the overview notes.
509-
#
510524
# If +tls+ is true, enable TLS. The default is false.
511525
# If +starttls+ is :always, enable STARTTLS, if +:auto+, use STARTTLS when the server supports it,
512526
# if false, disable STARTTLS.
@@ -520,6 +534,13 @@ def debug_output=(arg)
520534
#
521535
# +tls_verify: true+ is equivalent to +ssl_context_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER }+.
522536
#
537+
# The remaining arguments are used for SMTP authentication, if required or
538+
# desired. +user+ or +username+ is the authentication or authorization
539+
# identity (depending on +authtype+); +secret+ or +password+ is your
540+
# password or other authentication token; and +authtype+ is the
541+
# authentication type. +auth+ is a hash of arbitrary keyword parameters for
542+
# #auth. See the discussion of SMTP Authentication in the overview notes.
543+
#
523544
# === Errors
524545
#
525546
# This method may raise:
@@ -565,10 +586,13 @@ def started?
565586
# +helo+ is the _HELO_ _domain_ that you'll dispatch mails from; see
566587
# the discussion in the overview notes.
567588
#
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.
589+
# If +user+, +username+, +secret+, +password+, +authtype+, or +auth+ given,
590+
# SMTP authentication will be attempted using the #auth command. +authtype+
591+
# specifies the SASL mechanism to attempt; +user+ or +username+ is the
592+
# authentication or authorization identity (depending on +authtype+);
593+
# +secret+ or +password+ is your password or other authentication token;
594+
# +auth+ is a hash of arbitrary keyword parameters for #auth. See the
595+
# discussion of SMTP Authentication in the overview notes.
572596
#
573597
# === Block Usage
574598
#
@@ -871,15 +895,16 @@ def authenticate(user, secret, authtype = DEFAULT_AUTH_TYPE)
871895
# include +authcid+ for authentication identity, +authzid+ for authorization
872896
# identity, +username+ for either "authentication identity" or
873897
# "authorization identity" depending on the +mechanism+, and +password+.
898+
# Keyword arguments that do not apply to the +mechanism+ may be silently
899+
# ignored.
874900
def auth(*args, **kwargs, &blk)
875901
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) }
902+
critical do
903+
Authenticator::SASLAdapter.new(self).authenticate(*args, **kwargs, &blk)
904+
rescue SMTPServerBusy, SMTPSyntaxError, SMTPFatalError => error
905+
raise SMTPAuthenticationError.new(error.response)
906+
rescue SASL::AuthenticationIncomplete => error
907+
raise error.response.exception_class.new(error.response)
883908
end
884909
end
885910

@@ -919,21 +944,28 @@ def merge_auth_params(user, secret, authtype, auth)
919944
auth
920945
end
921946

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
947+
def backward_compatible_auth_args(authtype = nil, *args,
948+
type: nil, secret: nil, **kwargs)
949+
type && authtype and
929950
raise ArgumentError, 'conflict between "type" keyword argument ' \
930951
'and positional argument'
931-
type ||= _type || DEFAULT_AUTH_TYPE
952+
type ||= authtype || DEFAULT_AUTH_TYPE
932953
check_auth_method(type)
954+
if secret
955+
secret_type = type.match?(/\AX?OAUTH/i) ? :oauth2_token : :password
956+
kwargs.key?(secret_type) and
957+
raise ArgumentError 'conflict between "secret" and %p keyword args' % [
958+
secret_type.to_s
959+
]
960+
kwargs[secret_type] = secret
961+
end
933962
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
963+
if auth_class.is_a?(Class) && auth_class <= Authenticator ||
964+
type.match?(/\A(?:LOGIN|CRAM[-_]MD5)\z/i)
965+
usernames = [kwargs.delete(:authcid), kwargs.delete(:username)]
966+
secrets = [kwargs.delete(:password)]
967+
args[0] ||= usernames.compact.first
968+
args[1] ||= secrets.compact.first
937969
check_auth_args(args[0], args[1], type)
938970
end
939971
[[type, *args], kwargs]
@@ -1047,6 +1079,27 @@ def get_response(reqline)
10471079
recv_response()
10481080
end
10491081

1082+
# Returns a successful Response.
1083+
#
1084+
# Yields continuation data.
1085+
#
1086+
# This method may raise:
1087+
#
1088+
# * Net::SMTPAuthenticationError
1089+
# * Net::SMTPServerBusy
1090+
# * Net::SMTPSyntaxError
1091+
# * Net::SMTPFatalError
1092+
# * Net::SMTPUnknownError
1093+
def send_command_with_continuations(*args)
1094+
server_resp = get_response args.join(" ")
1095+
while server_resp.continue?
1096+
client_resp = yield server_resp.string.strip.split(nil, 2).last
1097+
server_resp = get_response client_resp
1098+
end
1099+
server_resp.success? or raise server_resp.exception_class.new(server_resp)
1100+
server_resp
1101+
end
1102+
10501103
private
10511104

10521105
def validate_line(line)

lib/net/smtp/auth_sasl_adapter.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
5+
module Net
6+
class SMTP
7+
SASL = Net::IMAP::SASL
8+
9+
class Authenticator
10+
11+
# Experimental
12+
#
13+
# Initialize with a block that runs a command, yielding for continuations.
14+
class SASLAdapter < SASL::ClientAdapter
15+
include SASL::ProtocolAdapters::SMTP
16+
17+
RESPONSE_ERRORS = [
18+
SMTPAuthenticationError,
19+
SMTPServerBusy,
20+
SMTPSyntaxError,
21+
SMTPFatalError,
22+
].freeze
23+
24+
def initialize(...)
25+
super
26+
@command_proc ||= client.method(:send_command_with_continuations)
27+
end
28+
29+
def host; client.address end
30+
def response_errors; RESPONSE_ERRORS end
31+
def sasl_ir_capable?; true end # TODO
32+
def auth_capable?(mechanism); client.auth_capable?(mechanism) end
33+
def drop_connection; client.finish end
34+
def drop_connection!; client.finish end # TODO
35+
end
36+
37+
end
38+
end
39+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class SMTP
5+
6+
# Curries arguments to SMTP#auth, using the Authenticator API.
7+
#
8+
# Net::SMTP#authenticate still supports the v0.4.0 Authenticator API, so
9+
# Authenticator subclasses can still be added and used with it. This class
10+
# will be used as the default, when no matching Authenticator subclass
11+
# exists.
12+
class CompatibilityAdapter
13+
def initialize(mechanism) @mechanism = mechanism end
14+
def new(smtp) @smtp = smtp; self end
15+
def auth(*args, **kwargs, &block)
16+
args.pop while args.any? && args.last.nil?
17+
@smtp.auth(@mechanism, *args, **kwargs, &block)
18+
end
19+
end
20+
21+
Authenticator.auth_classes.default_proc = ->_, mechanism {
22+
CompatibilityAdapter.new(mechanism)
23+
}
24+
25+
end
26+
end

net-smtp.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ Gem::Specification.new do |spec|
2626
spec.require_paths = ["lib"]
2727

2828
spec.add_dependency "net-protocol"
29+
spec.add_dependency "net-imap", ">= 0.4.1" # experimental SASL support
2930
end

test/net/smtp/test_smtp.rb

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -530,16 +530,6 @@ def test_start_auth_cram_md5
530530
assert_raise Net::SMTPAuthenticationError do
531531
Net::SMTP.start('localhost', port, user: 'account', password: 'password', authtype: :cram_md5){}
532532
end
533-
534-
port = fake_server_start(auth: 'CRAM-MD5')
535-
smtp = Net::SMTP.new('localhost', port)
536-
auth_cram_md5 = Net::SMTP::AuthCramMD5.new(smtp)
537-
auth_cram_md5.define_singleton_method(:digest_class) { raise '"openssl" or "digest" library is required' }
538-
Net::SMTP::AuthCramMD5.define_singleton_method(:new) { |_| auth_cram_md5 }
539-
e = assert_raise RuntimeError do
540-
smtp.start(user: 'account', password: 'password', authtype: :cram_md5){}
541-
end
542-
assert_equal('"openssl" or "digest" library is required', e.message)
543533
end
544534

545535
def test_start_instance

0 commit comments

Comments
 (0)