Skip to content

Commit 9eca34f

Browse files
committed
✨ Add basic SASL-IR support to #authenticate
I'd like the global default for `:sasl_ir` value to be configurable, but that can come in another PR.
1 parent b8f2986 commit 9eca34f

File tree

8 files changed

+153
-26
lines changed

8 files changed

+153
-26
lines changed

lib/net/imap.rb

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -404,14 +404,14 @@ module Net
404404
#
405405
# Although IMAP4rev2[https://tools.ietf.org/html/rfc9051] is not supported
406406
# yet, Net::IMAP supports several extensions that have been folded into it:
407-
# +ENABLE+, +IDLE+, +MOVE+, +NAMESPACE+, +UIDPLUS+, and +UNSELECT+. Commands
408-
# for these extensions are listed with the
409-
# {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands], above.
407+
# +ENABLE+, +IDLE+, +MOVE+, +NAMESPACE+, +SASL-IR+, +UIDPLUS+, and +UNSELECT+.
408+
# Commands for these extensions are listed with the {Core IMAP
409+
# commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands], above.
410410
#
411411
# >>>
412412
# <em>The following are folded into +IMAP4rev2+ but are currently
413413
# unsupported or incompletely supported by</em> Net::IMAP<em>: RFC4466
414-
# extensions, +ESEARCH+, +SEARCHRES+, +SASL-IR+, +LIST-EXTENDED+,
414+
# extensions, +ESEARCH+, +SEARCHRES+, +LIST-EXTENDED+,
415415
# +LIST-STATUS+, +LITERAL-+, +BINARY+ fetch, and +SPECIAL-USE+. The
416416
# following extensions are implicitly supported, but will be updated with
417417
# more direct support: RFC5530 response codes, <tt>STATUS=SIZE</tt>, and
@@ -457,6 +457,10 @@ module Net
457457
# - Updates #append with the +APPENDUID+ ResponseCode
458458
# - Updates #copy, #move with the +COPYUID+ ResponseCode
459459
#
460+
# ==== RFC4959: +SASL-IR+
461+
# Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051].
462+
# - Updates #authenticate with the option to send an initial response.
463+
#
460464
# ==== RFC5161: +ENABLE+
461465
# Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051] and also included
462466
# above with {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands].
@@ -983,21 +987,22 @@ def starttls(options = {}, verify = true)
983987
end
984988

985989
# :call-seq:
986-
# authenticate(mechanism, ...) -> ok_resp
987-
# authenticate(mech, *creds, **props) {|prop, auth| val } -> ok_resp
988-
# authenticate(mechanism, authnid, credentials, authzid=nil) -> ok_resp
989-
# authenticate(mechanism, **properties) -> ok_resp
990-
# authenticate(mechanism) {|propname, authctx| prop_value } -> ok_resp
990+
# authenticate(mechanism, ...) -> ok_resp
991+
# authenticate(mech, *creds, sasl_ir: nil, **props, &callback) -> ok_resp
991992
#
992993
# Sends an {AUTHENTICATE command [IMAP4rev1 §6.2.2]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.2]
993994
# to authenticate the client. If successful, the connection enters the
994995
# "_authenticated_" state.
995996
#
996997
# +mechanism+ is the name of the \SASL authentication mechanism to be used.
998+
#
999+
# When supported by the chosen mechanism and by the server #capabilities,
1000+
# <tt>sasl_ir: :auto</tt> sends an "initial response" to save a round trip.
1001+
# <em>A future version may default to +:auto+.</em>
1002+
#
9971003
# All other arguments are forwarded to the authenticator for the requested
998-
# mechanism. The listed call signatures are suggestions. <em>The
999-
# documentation for each individual mechanism must be consulted for its
1000-
# specific parameters.</em>
1004+
# mechanism. <em>The documentation for each individual mechanism must be
1005+
# consulted for its specific parameters.</em>
10011006
#
10021007
# An exception Net::IMAP::NoResponseError is raised if authentication fails.
10031008
#
@@ -1048,19 +1053,38 @@ def starttls(options = {}, verify = true)
10481053
# raise "No acceptable authentication mechanism is available"
10491054
# end
10501055
#
1051-
# Server capabilities may change after #starttls, #login, and #authenticate.
1052-
# Cached #capabilities will be cleared when this method completes.
1053-
# If the TaggedResponse to #authenticate includes updated capabilities, they
1054-
# will be cached.
1056+
# When the server's capabilities include +SASL-IR+
1057+
# [RFC4959[https://tools.ietf.org/html/rfc4959]], an initial response may be
1058+
# sent with +sasl_ir+ set to +true+ or +:auto+.
1059+
#
1060+
# # The following two commands are equivalent:
1061+
# imap.authenticate("PLAIN", user, password, sasl_ir: :auto)
1062+
# imap.authenticate("PLAIN", user, password,
1063+
# sasl_ir: imap.capable?("SASL-IR"))
10551064
#
1056-
def authenticate(mechanism, ...)
1057-
authenticator = self.class.authenticator(mechanism, ...)
1058-
send_command("AUTHENTICATE", mechanism) do |resp|
1065+
# Server capabilities may change after #starttls, #login, and #authenticate.
1066+
# Previously Cached #capabilities will be cleared when this method
1067+
# completes. If the TaggedResponse to #authenticate includes updated
1068+
# capabilities, they will be cached.
1069+
def authenticate(mechanism, *creds, sasl_ir: false, **props, &callback)
1070+
[true, false, :auto].include?(sasl_ir) or
1071+
raise ArgumentError, "sasl_ir must be boolean or :auto"
1072+
sasl_ir = capable?("SASL-IR") if :auto == sasl_ir
1073+
authenticator = self.class.authenticator(mechanism,
1074+
*creds,
1075+
**props,
1076+
&callback)
1077+
cmdargs = ["AUTHENTICATE", mechanism]
1078+
if sasl_ir && SASL.initial_response?(authenticator)
1079+
response = authenticator.process(nil)
1080+
cmdargs << [response].pack("m0")
1081+
end
1082+
send_command(*cmdargs) do |resp|
10591083
if resp.instance_of?(ContinuationRequest)
1060-
data = authenticator.process(resp.data.text.unpack("m")[0])
1061-
s = [data].pack("m0")
1062-
send_string_data(s)
1063-
put_string(CRLF)
1084+
challenge = resp.data.text.unpack1("m")
1085+
response = authenticator.process(challenge)
1086+
response = [response].pack("m0")
1087+
put_string(response + CRLF)
10641088
end
10651089
end
10661090
.tap { @capabilities = capabilities_from_resp_code _1 }

lib/net/imap/authenticators/plain.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
# can be secured by TLS encryption.
1212
class Net::IMAP::PlainAuthenticator
1313

14+
def initial_response?; true end
15+
1416
def process(data)
1517
return "#@authzid\0#@username\0#@password"
1618
end

lib/net/imap/sasl.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ def saslprep(string, **opts)
3939
Net::IMAP::StringPrep::SASLprep.saslprep(string, **opts)
4040
end
4141

42+
def initial_response?(mechanism)
43+
mechanism.respond_to?(:initial_response?) && mechanism.initial_response?
44+
end
45+
4246
end
4347
end
4448

test/net/imap/fake_server/command_reader.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def get_command
3232
def parse(buf)
3333
/\A([^ ]+) ((?:UID )?\w+)(?: (.+))?\r\n\z/min =~ buf or raise "bad request"
3434
case $2.upcase
35-
when "LOGIN", "SELECT", "ENABLE"
35+
when "LOGIN", "SELECT", "ENABLE", "AUTHENTICATE"
3636
Command.new $1, $2, scan_astrings($3), buf
3737
else
3838
Command.new $1, $2, $3, buf # TODO...

test/net/imap/fake_server/command_router.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,14 @@ def handler_for(command)
7979
on "AUTHENTICATE" do |resp|
8080
state.not_authenticated? or return resp.fail_bad_state(state)
8181
args = resp.command.args
82-
args == "PLAIN" or return resp.fail_no "unsupported"
83-
response_b64 = resp.request_continuation("") || ""
82+
(1..2) === args.length or return resp.fail_bad_args
83+
args.first == "PLAIN" or return resp.fail_no "unsupported"
84+
if args.length == 2
85+
response_b64 = args.last
86+
else
87+
response_b64 = resp.request_continuation("") || ""
88+
state.commands << {continuation: response_b64}
89+
end
8490
response = Base64.decode64(response_b64)
8591
response.empty? and return resp.fail_bad "canceled"
8692
# TODO: support mechanisms other than PLAIN.

test/net/imap/fake_server/configuration.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class Configuration
2222
encrypted_login: true,
2323
cleartext_auth: false,
2424
sasl_mechanisms: %i[PLAIN].freeze,
25+
sasl_ir: false,
2526

2627
rev1: true,
2728
rev2: false,
@@ -66,6 +67,7 @@ def initialize(with_extensions: [], without_extensions: [], **opts, &block)
6667
alias cleartext_auth? cleartext_auth
6768
alias greeting_bye? greeting_bye
6869
alias greeting_capabilities? greeting_capabilities
70+
alias sasl_ir? sasl_ir
6971

7072
def on(event, &handler)
7173
handler or raise ArgumentError
@@ -104,13 +106,15 @@ def capabilities_pre_tls
104106
capa << "STARTTLS" if starttls?
105107
capa << "LOGINDISABLED" unless cleartext_login?
106108
capa.concat auth_capabilities if cleartext_auth?
109+
capa << "SASL-IR" if sasl_ir? && cleartext_auth?
107110
capa
108111
end
109112

110113
def capabilities_pre_auth
111114
capa = basic_capabilities
112115
capa << "LOGINDISABLED" unless encrypted_login?
113116
capa.concat auth_capabilities
117+
capa << "SASL-IR" if sasl_ir?
114118
capa
115119
end
116120

test/net/imap/test_imap.rb

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,91 @@ def test_id
776776
end
777777
end
778778

779+
test "#authenticate doesn't send initial response by default" do
780+
[true, false].each do |server_support|
781+
with_fake_server(
782+
preauth: false, cleartext_auth: true, sasl_ir: server_support
783+
) do |server, imap|
784+
imap.authenticate("PLAIN", "test_user", "test-password")
785+
cmd, cont = 2.times.map { server.commands.pop }
786+
assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
787+
assert_equal(["\x00test_user\x00test-password"].pack("m0"),
788+
cont[:continuation].strip)
789+
assert_empty server.commands
790+
end
791+
end
792+
end
793+
794+
test "#authenticate(sasl_ir: false) doesn't send initial response " do
795+
[true, false].each do |server_support|
796+
with_fake_server(
797+
preauth: false, cleartext_auth: true, sasl_ir: server_support
798+
) do |server, imap|
799+
imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: false)
800+
cmd, cont = 2.times.map { server.commands.pop }
801+
assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
802+
assert_equal(["\x00test_user\x00test-password"].pack("m0"),
803+
cont[:continuation].strip)
804+
assert_empty server.commands
805+
end
806+
end
807+
end
808+
809+
test "#authenticate(sasl_ir: :auto) doesn't send if server isn't capable" do
810+
with_fake_server(
811+
preauth: false, cleartext_auth: true, sasl_ir: false
812+
) do |server, imap|
813+
imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: :auto)
814+
cmd, cont = 2.times.map { server.commands.pop }
815+
assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
816+
assert_equal(["\x00test_user\x00test-password"].pack("m0"),
817+
cont[:continuation].strip)
818+
assert_empty server.commands
819+
end
820+
end
821+
822+
test "#authenticate(sasl_ir: :auto) sends if server is capable" do
823+
with_fake_server(
824+
preauth: false, cleartext_auth: true, sasl_ir: true
825+
) do |server, imap|
826+
imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: true)
827+
cmd = server.commands.pop
828+
assert_equal "AUTHENTICATE", cmd.name
829+
assert_equal(["PLAIN", ["\x00test_user\x00test-password"].pack("m0")],
830+
cmd.args)
831+
assert_empty server.commands
832+
end
833+
end
834+
835+
test "#authenticate(sasl_ir: true) sends initial response" do
836+
[true, false].each do |server_support|
837+
with_fake_server(
838+
preauth: false, cleartext_auth: true, sasl_ir: server_support
839+
) do |server, imap|
840+
imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: true)
841+
cmd = server.commands.pop
842+
assert_equal "AUTHENTICATE", cmd.name
843+
assert_equal(["PLAIN", ["\x00test_user\x00test-password"].pack("m0")],
844+
cmd.args)
845+
assert_empty server.commands
846+
end
847+
end
848+
end
849+
850+
test "#authenticate(sasl_ir: true) doesn't send IR for incapable mechanism" do
851+
with_fake_server(
852+
preauth: false, cleartext_auth: true, sasl_ir: true
853+
) do |server, imap|
854+
begin
855+
imap.authenticate("DIGEST-MD5", "test_user", "test-password",
856+
sasl_ir: true, warn_deprecation: false)
857+
rescue Net::IMAP::NoResponseError
858+
end
859+
cmd = server.commands.pop
860+
assert_equal %w[AUTHENTICATE DIGEST-MD5], [cmd.name, *cmd.args]
861+
end
862+
end
863+
779864
def test_uidplus_uid_expunge
780865
with_fake_server(select: "INBOX",
781866
extensions: %i[UIDPLUS]) do |server, imap|

test/net/imap/test_imap_capabilities.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ def teardown
190190

191191
imap.authenticate("PLAIN", "test_user", "test-password")
192192
assert_equal "AUTHENTICATE", server.commands.pop.name
193+
assert server.commands.pop[:continuation]
193194
refute imap.capabilities_cached?
194195

195196
assert imap.capable? :IMAP4rev1
@@ -277,6 +278,7 @@ def teardown
277278
rescue Net::IMAP::NoResponseError
278279
end
279280
assert_equal "AUTHENTICATE", server.commands.pop.name
281+
assert server.commands.pop[:continuation]
280282
assert_equal original_capabilities, imap.capabilities
281283
assert_empty server.commands
282284
end

0 commit comments

Comments
 (0)