Skip to content

Commit dcfc541

Browse files
committed
Use net-imap's SASL implementation 🚧[WIP]🚧
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.
1 parent 64e9b56 commit dcfc541

File tree

5 files changed

+146
-15
lines changed

5 files changed

+146
-15
lines changed

lib/net/smtp.rb

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,8 @@ class SMTPUnsupportedCommand < ProtocolError
175175
#
176176
# The Net::SMTP class supports the \SMTP extension for SASL Authentication
177177
# [RFC4954[https://www.rfc-editor.org/rfc/rfc4954.html]] and the following
178-
# SASL mechanisms: +PLAIN+, +LOGIN+ _(deprecated)_, and +CRAM-MD5+
179-
# _(deprecated)_.
178+
# SASL mechanisms: +ANONYMOUS+, +EXTERNAL+, +OAUTHBEARER+, +PLAIN+,
179+
# +SCRAM-SHA-1+, +SCRAM-SHA-256+, and +XOAUTH2+.
180180
#
181181
# To use \SMTP authentication, pass extra arguments to
182182
# SMTP.start or SMTP#start.
@@ -190,10 +190,38 @@ class SMTPUnsupportedCommand < ProtocolError
190190
# password: password,
191191
# authzid: "authorization identity"}) # optional
192192
#
193-
# Support for other SASL mechanisms—such as +EXTERNAL+, +OAUTHBEARER+,
194-
# +SCRAM-SHA-256+, and +XOAUTH2+—will be added in a future release.
193+
# # SCRAM-SHA-256
194+
# Net::SMTP.start("your.smtp.server", 25,
195+
# user: "authentication identity", secret: password,
196+
# authtype: :scram_sha_256)
197+
# Net::SMTP.start("your.smtp.server", 25,
198+
# auth: {type: :scram_sha_256,
199+
# username: "authentication identity",
200+
# password: password,
201+
# authzid: "authorization identity"}) # optional
202+
#
203+
# # OAUTHBEARER
204+
# Net::SMTP.start("your.smtp.server", 25,
205+
# auth: {type: :oauthbearer,
206+
# oauth2_token: oauth2_access_token,
207+
# authzid: "authorization identity", # optional
208+
# host: "your.smtp.server", # optional
209+
# port: 25}) # optional
210+
#
211+
# # XOAUTH2
212+
# Net::SMTP.start("your.smtp.server", 25,
213+
# user: "username", secret: oauth2_access_token, authtype: :xoauth2)
214+
# Net::SMTP.start("your.smtp.server", 25,
215+
# auth: {type: :xoauth2,
216+
# username: "username",
217+
# oauth2_token: oauth2_token})
218+
#
219+
# # EXTERNAL
220+
# Net::SMTP.start("your.smtp.server", 587,
221+
# starttls: :always, ssl_context_params: ssl_ctx_params,
222+
# authtype: "external")
195223
#
196-
# The +LOGIN+ and +CRAM-MD5+ mechanisms are still available for backwards
224+
# +DIGEST-MD5+, +LOGIN+, and +CRAM-MD5+ are still available for backwards
197225
# compatibility, but are deprecated and should be avoided.
198226
#
199227
class SMTP < Protocol
@@ -1038,6 +1066,27 @@ def get_response(reqline)
10381066
recv_response()
10391067
end
10401068

1069+
# Returns a successful Response.
1070+
#
1071+
# Yields continuation data.
1072+
#
1073+
# This method may raise:
1074+
#
1075+
# * Net::SMTPAuthenticationError
1076+
# * Net::SMTPServerBusy
1077+
# * Net::SMTPSyntaxError
1078+
# * Net::SMTPFatalError
1079+
# * Net::SMTPUnknownError
1080+
def send_command_with_continuations(*args)
1081+
server_resp = get_response args.join(" ")
1082+
while server_resp.continue?
1083+
client_resp = yield server_resp.string.strip.split(nil, 2).last
1084+
server_resp = get_response client_resp
1085+
end
1086+
server_resp.success? or raise server_resp.exception_class.new(server_resp)
1087+
server_resp
1088+
end
1089+
10411090
private
10421091

10431092
def validate_line(line)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# frozen_string_literal: true
2+
3+
require "net/imap"
4+
5+
module Net
6+
class SMTP
7+
SASL = Net::IMAP::SASL
8+
9+
# Experimental
10+
#
11+
# Initialize with a block that runs a command, yielding for continuations.
12+
class SASLClientAdapter < SASL::ClientAdapter
13+
include SASL::ProtocolAdapters::SMTP
14+
15+
RESPONSE_ERRORS = [
16+
SMTPAuthenticationError,
17+
SMTPServerBusy,
18+
SMTPSyntaxError,
19+
SMTPFatalError,
20+
].freeze
21+
22+
def initialize(...)
23+
super
24+
@command_proc ||= client.method(:send_command_with_continuations)
25+
end
26+
27+
# Translates +user+ to +username+ and +type+ kwarg to the first arg.
28+
def authenticate(typearg = nil, *args,
29+
type: nil, user: nil,
30+
**kwargs, &block)
31+
kwargs[:username] ||= user if user
32+
type ||= typearg || DEFAULT_AUTH_TYPE
33+
args, kwargs = backward_compatible_auth_args(type, *args, **kwargs)
34+
super(type, *args, **kwargs)
35+
rescue SMTPServerBusy, SMTPSyntaxError, SMTPFatalError => error
36+
raise SMTPAuthenticationError.new(error.response)
37+
rescue SASL::AuthenticationIncomplete => error
38+
raise error.response.exception_class.new(error.response)
39+
end
40+
41+
def host; client.address end
42+
def response_errors; RESPONSE_ERRORS end
43+
def sasl_ir_capable?; true end
44+
def drop_connection; client.finish end
45+
def drop_connection!; client.finish end
46+
47+
private
48+
49+
# Temporarily adapt for differences between SMTP and Net::IMAP::SASL:
50+
#
51+
# * Net::IMAP::SASL adapters don't handle the +secret+ keyword.
52+
# * Net::IMAP::SASL::LoginAdapter and CramMd5Adapter don't accept kwargs!
53+
#
54+
# These are reasonable changes and Net::IMAP::SASL can be updated.
55+
def backward_compatible_auth_args(type, *args, secret: nil, **kwargs)
56+
if secret
57+
secret_type = type.match?(/\AX?OAUTH/i) ? :oauth2_token : :password
58+
kwargs[secret_type] ||= secret
59+
end
60+
if type.match?(/\A(?:LOGIN|CRAM[-_]MD5)\z/i)
61+
usernames = [kwargs.delete(:authcid), kwargs.delete(:username)]
62+
secrets = [kwargs.delete(:password)]
63+
args[0] ||= usernames.compact.first
64+
args[1] ||= secrets.compact.first
65+
end
66+
[args, kwargs]
67+
end
68+
69+
end
70+
71+
end
72+
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class SMTP
5+
6+
# Curries arguments to SASLAdapter.authenticate.
7+
class AuthSASLCompatibilityAdapter
8+
def initialize(mechanism) @mechanism = mechanism end
9+
def check_args(...) SASL.authenticator(...) end
10+
def new(smtp) @sasl_adapter = SASLClientAdapter.new(smtp); self end
11+
def auth(...) @sasl_adapter.authenticate(@mechanism, ...) end
12+
end
13+
14+
Authenticator.auth_classes.default_proc = ->hash, mechanism {
15+
hash[mechanism] = AuthSASLCompatibilityAdapter.new(mechanism)
16+
}
17+
18+
end
19+
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)