diff --git a/lib/net/imap.rb b/lib/net/imap.rb
index 72fb8b0df..bdfa825ba 100644
--- a/lib/net/imap.rb
+++ b/lib/net/imap.rb
@@ -404,14 +404,14 @@ module Net
#
# Although IMAP4rev2[https://tools.ietf.org/html/rfc9051] is not supported
# yet, Net::IMAP supports several extensions that have been folded into it:
- # +ENABLE+, +IDLE+, +MOVE+, +NAMESPACE+, +UIDPLUS+, and +UNSELECT+. Commands
- # for these extensions are listed with the
- # {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands], above.
+ # +ENABLE+, +IDLE+, +MOVE+, +NAMESPACE+, +SASL-IR+, +UIDPLUS+, and +UNSELECT+.
+ # Commands for these extensions are listed with the {Core IMAP
+ # commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands], above.
#
# >>>
# The following are folded into +IMAP4rev2+ but are currently
# unsupported or incompletely supported by Net::IMAP: RFC4466
- # extensions, +ESEARCH+, +SEARCHRES+, +SASL-IR+, +LIST-EXTENDED+,
+ # extensions, +ESEARCH+, +SEARCHRES+, +LIST-EXTENDED+,
# +LIST-STATUS+, +LITERAL-+, +BINARY+ fetch, and +SPECIAL-USE+. The
# following extensions are implicitly supported, but will be updated with
# more direct support: RFC5530 response codes, STATUS=SIZE, and
@@ -457,6 +457,10 @@ module Net
# - Updates #append with the +APPENDUID+ ResponseCode
# - Updates #copy, #move with the +COPYUID+ ResponseCode
#
+ # ==== RFC4959: +SASL-IR+
+ # Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051].
+ # - Updates #authenticate with the option to send an initial response.
+ #
# ==== RFC5161: +ENABLE+
# Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051] and also included
# above with {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands].
@@ -983,21 +987,22 @@ def starttls(options = {}, verify = true)
end
# :call-seq:
- # authenticate(mechanism, ...) -> ok_resp
- # authenticate(mech, *creds, **props) {|prop, auth| val } -> ok_resp
- # authenticate(mechanism, authnid, credentials, authzid=nil) -> ok_resp
- # authenticate(mechanism, **properties) -> ok_resp
- # authenticate(mechanism) {|propname, authctx| prop_value } -> ok_resp
+ # authenticate(mechanism, ...) -> ok_resp
+ # authenticate(mech, *creds, sasl_ir: nil, **props, &callback) -> ok_resp
#
# Sends an {AUTHENTICATE command [IMAP4rev1 ยง6.2.2]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.2]
# to authenticate the client. If successful, the connection enters the
# "_authenticated_" state.
#
# +mechanism+ is the name of the \SASL authentication mechanism to be used.
+ #
+ # When supported by the chosen mechanism and by the server #capabilities,
+ # sasl_ir: :auto sends an "initial response" to save a round trip.
+ # A future version may default to +:auto+.
+ #
# All other arguments are forwarded to the authenticator for the requested
- # mechanism. The listed call signatures are suggestions. The
- # documentation for each individual mechanism must be consulted for its
- # specific parameters.
+ # mechanism. The documentation for each individual mechanism must be
+ # consulted for its specific parameters.
#
# An exception Net::IMAP::NoResponseError is raised if authentication fails.
#
@@ -1048,19 +1053,38 @@ def starttls(options = {}, verify = true)
# raise "No acceptable authentication mechanism is available"
# end
#
- # Server capabilities may change after #starttls, #login, and #authenticate.
- # Cached #capabilities will be cleared when this method completes.
- # If the TaggedResponse to #authenticate includes updated capabilities, they
- # will be cached.
+ # When the server's capabilities include +SASL-IR+
+ # [RFC4959[https://tools.ietf.org/html/rfc4959]], an initial response may be
+ # sent with +sasl_ir+ set to +true+ or +:auto+.
+ #
+ # # The following two commands are equivalent:
+ # imap.authenticate("PLAIN", user, password, sasl_ir: :auto)
+ # imap.authenticate("PLAIN", user, password,
+ # sasl_ir: imap.capable?("SASL-IR"))
#
- def authenticate(mechanism, ...)
- authenticator = self.class.authenticator(mechanism, ...)
- send_command("AUTHENTICATE", mechanism) do |resp|
+ # Server capabilities may change after #starttls, #login, and #authenticate.
+ # Previously Cached #capabilities will be cleared when this method
+ # completes. If the TaggedResponse to #authenticate includes updated
+ # capabilities, they will be cached.
+ def authenticate(mechanism, *creds, sasl_ir: false, **props, &callback)
+ [true, false, :auto].include?(sasl_ir) or
+ raise ArgumentError, "sasl_ir must be boolean or :auto"
+ sasl_ir = capable?("SASL-IR") if :auto == sasl_ir
+ authenticator = self.class.authenticator(mechanism,
+ *creds,
+ **props,
+ &callback)
+ cmdargs = ["AUTHENTICATE", mechanism]
+ if sasl_ir && SASL.initial_response?(authenticator)
+ response = authenticator.process(nil)
+ cmdargs << [response].pack("m0")
+ end
+ send_command(*cmdargs) do |resp|
if resp.instance_of?(ContinuationRequest)
- data = authenticator.process(resp.data.text.unpack("m")[0])
- s = [data].pack("m0")
- send_string_data(s)
- put_string(CRLF)
+ challenge = resp.data.text.unpack1("m")
+ response = authenticator.process(challenge)
+ response = [response].pack("m0")
+ put_string(response + CRLF)
end
end
.tap { @capabilities = capabilities_from_resp_code _1 }
diff --git a/lib/net/imap/authenticators/plain.rb b/lib/net/imap/authenticators/plain.rb
index a9d46c920..e7fe07c4d 100644
--- a/lib/net/imap/authenticators/plain.rb
+++ b/lib/net/imap/authenticators/plain.rb
@@ -11,6 +11,8 @@
# can be secured by TLS encryption.
class Net::IMAP::PlainAuthenticator
+ def initial_response?; true end
+
def process(data)
return "#@authzid\0#@username\0#@password"
end
diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb
index 658580728..eca77bdd3 100644
--- a/lib/net/imap/sasl.rb
+++ b/lib/net/imap/sasl.rb
@@ -39,6 +39,10 @@ def saslprep(string, **opts)
Net::IMAP::StringPrep::SASLprep.saslprep(string, **opts)
end
+ def initial_response?(mechanism)
+ mechanism.respond_to?(:initial_response?) && mechanism.initial_response?
+ end
+
end
end
diff --git a/test/net/imap/fake_server/command_reader.rb b/test/net/imap/fake_server/command_reader.rb
index 2abe2f8a4..2fd15ef2f 100644
--- a/test/net/imap/fake_server/command_reader.rb
+++ b/test/net/imap/fake_server/command_reader.rb
@@ -32,7 +32,7 @@ def get_command
def parse(buf)
/\A([^ ]+) ((?:UID )?\w+)(?: (.+))?\r\n\z/min =~ buf or raise "bad request"
case $2.upcase
- when "LOGIN", "SELECT", "ENABLE"
+ when "LOGIN", "SELECT", "ENABLE", "AUTHENTICATE"
Command.new $1, $2, scan_astrings($3), buf
else
Command.new $1, $2, $3, buf # TODO...
diff --git a/test/net/imap/fake_server/command_router.rb b/test/net/imap/fake_server/command_router.rb
index c99a0a989..96996148b 100644
--- a/test/net/imap/fake_server/command_router.rb
+++ b/test/net/imap/fake_server/command_router.rb
@@ -79,8 +79,14 @@ def handler_for(command)
on "AUTHENTICATE" do |resp|
state.not_authenticated? or return resp.fail_bad_state(state)
args = resp.command.args
- args == "PLAIN" or return resp.fail_no "unsupported"
- response_b64 = resp.request_continuation("") || ""
+ (1..2) === args.length or return resp.fail_bad_args
+ args.first == "PLAIN" or return resp.fail_no "unsupported"
+ if args.length == 2
+ response_b64 = args.last
+ else
+ response_b64 = resp.request_continuation("") || ""
+ state.commands << {continuation: response_b64}
+ end
response = Base64.decode64(response_b64)
response.empty? and return resp.fail_bad "canceled"
# TODO: support mechanisms other than PLAIN.
diff --git a/test/net/imap/fake_server/configuration.rb b/test/net/imap/fake_server/configuration.rb
index b215f019f..91fe72a42 100644
--- a/test/net/imap/fake_server/configuration.rb
+++ b/test/net/imap/fake_server/configuration.rb
@@ -22,6 +22,7 @@ class Configuration
encrypted_login: true,
cleartext_auth: false,
sasl_mechanisms: %i[PLAIN].freeze,
+ sasl_ir: false,
rev1: true,
rev2: false,
@@ -66,6 +67,7 @@ def initialize(with_extensions: [], without_extensions: [], **opts, &block)
alias cleartext_auth? cleartext_auth
alias greeting_bye? greeting_bye
alias greeting_capabilities? greeting_capabilities
+ alias sasl_ir? sasl_ir
def on(event, &handler)
handler or raise ArgumentError
@@ -104,6 +106,7 @@ def capabilities_pre_tls
capa << "STARTTLS" if starttls?
capa << "LOGINDISABLED" unless cleartext_login?
capa.concat auth_capabilities if cleartext_auth?
+ capa << "SASL-IR" if sasl_ir? && cleartext_auth?
capa
end
@@ -111,6 +114,7 @@ def capabilities_pre_auth
capa = basic_capabilities
capa << "LOGINDISABLED" unless encrypted_login?
capa.concat auth_capabilities
+ capa << "SASL-IR" if sasl_ir?
capa
end
diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb
index 949710227..8af57a4f2 100644
--- a/test/net/imap/test_imap.rb
+++ b/test/net/imap/test_imap.rb
@@ -776,6 +776,91 @@ def test_id
end
end
+ test "#authenticate doesn't send initial response by default" do
+ [true, false].each do |server_support|
+ with_fake_server(
+ preauth: false, cleartext_auth: true, sasl_ir: server_support
+ ) do |server, imap|
+ imap.authenticate("PLAIN", "test_user", "test-password")
+ cmd, cont = 2.times.map { server.commands.pop }
+ assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
+ assert_equal(["\x00test_user\x00test-password"].pack("m0"),
+ cont[:continuation].strip)
+ assert_empty server.commands
+ end
+ end
+ end
+
+ test "#authenticate(sasl_ir: false) doesn't send initial response " do
+ [true, false].each do |server_support|
+ with_fake_server(
+ preauth: false, cleartext_auth: true, sasl_ir: server_support
+ ) do |server, imap|
+ imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: false)
+ cmd, cont = 2.times.map { server.commands.pop }
+ assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
+ assert_equal(["\x00test_user\x00test-password"].pack("m0"),
+ cont[:continuation].strip)
+ assert_empty server.commands
+ end
+ end
+ end
+
+ test "#authenticate(sasl_ir: :auto) doesn't send if server isn't capable" do
+ with_fake_server(
+ preauth: false, cleartext_auth: true, sasl_ir: false
+ ) do |server, imap|
+ imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: :auto)
+ cmd, cont = 2.times.map { server.commands.pop }
+ assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
+ assert_equal(["\x00test_user\x00test-password"].pack("m0"),
+ cont[:continuation].strip)
+ assert_empty server.commands
+ end
+ end
+
+ test "#authenticate(sasl_ir: :auto) sends if server is capable" do
+ with_fake_server(
+ preauth: false, cleartext_auth: true, sasl_ir: true
+ ) do |server, imap|
+ imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: true)
+ cmd = server.commands.pop
+ assert_equal "AUTHENTICATE", cmd.name
+ assert_equal(["PLAIN", ["\x00test_user\x00test-password"].pack("m0")],
+ cmd.args)
+ assert_empty server.commands
+ end
+ end
+
+ test "#authenticate(sasl_ir: true) sends initial response" do
+ [true, false].each do |server_support|
+ with_fake_server(
+ preauth: false, cleartext_auth: true, sasl_ir: server_support
+ ) do |server, imap|
+ imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: true)
+ cmd = server.commands.pop
+ assert_equal "AUTHENTICATE", cmd.name
+ assert_equal(["PLAIN", ["\x00test_user\x00test-password"].pack("m0")],
+ cmd.args)
+ assert_empty server.commands
+ end
+ end
+ end
+
+ test "#authenticate(sasl_ir: true) doesn't send IR for incapable mechanism" do
+ with_fake_server(
+ preauth: false, cleartext_auth: true, sasl_ir: true
+ ) do |server, imap|
+ begin
+ imap.authenticate("DIGEST-MD5", "test_user", "test-password",
+ sasl_ir: true, warn_deprecation: false)
+ rescue Net::IMAP::NoResponseError
+ end
+ cmd = server.commands.pop
+ assert_equal %w[AUTHENTICATE DIGEST-MD5], [cmd.name, *cmd.args]
+ end
+ end
+
def test_uidplus_uid_expunge
with_fake_server(select: "INBOX",
extensions: %i[UIDPLUS]) do |server, imap|
diff --git a/test/net/imap/test_imap_capabilities.rb b/test/net/imap/test_imap_capabilities.rb
index 31f4d0ff8..14e1f79ba 100644
--- a/test/net/imap/test_imap_capabilities.rb
+++ b/test/net/imap/test_imap_capabilities.rb
@@ -190,6 +190,7 @@ def teardown
imap.authenticate("PLAIN", "test_user", "test-password")
assert_equal "AUTHENTICATE", server.commands.pop.name
+ assert server.commands.pop[:continuation]
refute imap.capabilities_cached?
assert imap.capable? :IMAP4rev1
@@ -277,6 +278,7 @@ def teardown
rescue Net::IMAP::NoResponseError
end
assert_equal "AUTHENTICATE", server.commands.pop.name
+ assert server.commands.pop[:continuation]
assert_equal original_capabilities, imap.capabilities
assert_empty server.commands
end