Skip to content

Commit 8e3173f

Browse files
committed
✨ Add basic SASL-IR support to #authenticate
I decided to enable SASL-IR by default. Because we check server capabilities (including #auth_capable?), this should be safe. This is the first (but not the last) command in Net::IMAP that changes its behavior based on #capabilities.
1 parent b8f2986 commit 8e3173f

File tree

10 files changed

+189
-29
lines changed

10 files changed

+189
-29
lines changed

lib/net/imap.rb

Lines changed: 45 additions & 22 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,19 +987,17 @@ 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: true, **attrs, &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.
997-
# All other arguments are forwarded to the authenticator for the requested
998-
# mechanism. The listed call signatures are suggestions. <em>The
998+
# +sasl_ir+ allows or disallows sending an "initial response" (see the
999+
# +SASL-IR+ capability, below). All other arguments are forwarded to the
1000+
# registered SASL authenticator for the requested mechanism. <em>The
9991001
# documentation for each individual mechanism must be consulted for its
10001002
# specific parameters.</em>
10011003
#
@@ -1048,19 +1050,40 @@ def starttls(options = {}, verify = true)
10481050
# raise "No acceptable authentication mechanism is available"
10491051
# end
10501052
#
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.
1053+
# The SASL exchange provides a method for server challenges and client
1054+
# responses, but many mechanisms expect the client to "respond" first. When
1055+
# the server's capabilities include +SASL-IR+
1056+
# [RFC4959[https://tools.ietf.org/html/rfc4959]], this "initial response"
1057+
# may be sent as an argument to the +AUTHENTICATE+ command, saving a
1058+
# round-trip. The initial response will _only_ be sent when it is supported
1059+
# by both the mechanism and the server. Set +sasl_ir+ to +false+ to prevent
1060+
# sending an initial response, even when it is supported.
10551061
#
1056-
def authenticate(mechanism, ...)
1057-
authenticator = self.class.authenticator(mechanism, ...)
1058-
send_command("AUTHENTICATE", mechanism) do |resp|
1062+
# Although servers _should_ advertise all supported auth mechanisms, it is
1063+
# possible to attempt to authenticate with a +mechanism+ that isn't listed.
1064+
# However the initial response will not be sent unless the appropriate
1065+
# <tt>"AUTH=#{mechanism}"</tt> capability is also present.
1066+
#
1067+
# Server capabilities may change after #starttls, #login, and #authenticate.
1068+
# Previously cached #capabilities will be cleared when this method
1069+
# completes. If the TaggedResponse to #authenticate includes updated
1070+
# capabilities, they will be cached.
1071+
def authenticate(mechanism, *creds, sasl_ir: true, **props, &callback)
1072+
authenticator = self.class.authenticator(mechanism,
1073+
*creds,
1074+
**props,
1075+
&callback)
1076+
cmdargs = ["AUTHENTICATE", mechanism]
1077+
if sasl_ir && capable?("SASL-IR") && auth_capable?(mechanism) &&
1078+
SASL.initial_response?(authenticator)
1079+
cmdargs << [authenticator.process(nil)].pack("m0")
1080+
end
1081+
send_command(*cmdargs) do |resp|
10591082
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)
1083+
challenge = resp.data.text.unpack1("m")
1084+
response = authenticator.process(challenge)
1085+
response = [response].pack("m0")
1086+
put_string(response + CRLF)
10641087
end
10651088
end
10661089
.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/authenticators/xoauth2.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# frozen_string_literal: true
22

33
class Net::IMAP::XOauth2Authenticator
4+
5+
def initial_response?; true end
6+
47
def process(_data)
58
build_oauth2_string(@user, @oauth2_token)
69
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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,96 @@ def test_id
776776
end
777777
end
778778

779+
test("#authenticate sends an initial response " \
780+
"when supported by both the mechanism and the server") do
781+
with_fake_server(
782+
preauth: false, cleartext_auth: true, sasl_ir: true
783+
) do |server, imap|
784+
imap.authenticate("PLAIN", "test_user", "test-password")
785+
cmd = server.commands.pop
786+
assert_equal "AUTHENTICATE", cmd.name
787+
assert_equal(["PLAIN", ["\x00test_user\x00test-password"].pack("m0")],
788+
cmd.args)
789+
assert_empty server.commands
790+
end
791+
end
792+
793+
test("#authenticate never sends an initial response " \
794+
"when the server doesn't explicitly support the mechanism") do
795+
with_fake_server(
796+
preauth: false, cleartext_auth: true,
797+
sasl_ir: true, sasl_mechanisms: %i[SCRAM-SHA-1 SCRAM-SHA-256],
798+
) do |server, imap|
799+
imap.authenticate("PLAIN", "test_user", "test-password")
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+
808+
test("#authenticate never sends an initial response " \
809+
"when the 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")
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 never sends an initial response " \
823+
"when sasl_ir: false") do
824+
[true, false].each do |server_support|
825+
with_fake_server(
826+
preauth: false, cleartext_auth: true, sasl_ir: server_support
827+
) do |server, imap|
828+
imap.authenticate("PLAIN", "test_user", "test-password", sasl_ir: false)
829+
cmd, cont = 2.times.map { server.commands.pop }
830+
assert_equal %w[AUTHENTICATE PLAIN], [cmd.name, *cmd.args]
831+
assert_equal(["\x00test_user\x00test-password"].pack("m0"),
832+
cont[:continuation].strip)
833+
assert_empty server.commands
834+
end
835+
end
836+
end
837+
838+
test("#authenticate never sends an initial response " \
839+
"when the mechanism does not support client-first") do
840+
with_fake_server(
841+
preauth: false, cleartext_auth: true,
842+
sasl_ir: true, sasl_mechanisms: %i[DIGEST-MD5]
843+
) do |server, imap|
844+
server.on "AUTHENTICATE" do |cmd|
845+
response_b64 = cmd.request_continuation(
846+
[
847+
%w[
848+
realm="somerealm"
849+
nonce="OA6MG9tEQGm2hh"
850+
qop="auth"
851+
charset=utf-8
852+
algorithm=md5-sess
853+
].join(",")
854+
].pack("m0")
855+
)
856+
state.commands << {continuation: response_b64}
857+
server.state.authenticate(server.config.user)
858+
cmd.done_ok
859+
end
860+
imap.authenticate("DIGEST-MD5", "test_user", "test-password",
861+
warn_deprecation: false)
862+
cmd, cont = 2.times.map { server.commands.pop }
863+
assert_equal %w[AUTHENTICATE DIGEST-MD5], [cmd.name, *cmd.args]
864+
assert_match(%r{\A[a-z0-9+/]+=*\z}i, cont[:continuation].strip)
865+
assert_empty server.commands
866+
end
867+
end
868+
779869
def test_uidplus_uid_expunge
780870
with_fake_server(select: "INBOX",
781871
extensions: %i[UIDPLUS]) do |server, imap|

test/net/imap/test_imap_authenticators.rb

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ class IMAPAuthenticatorsTest < Test::Unit::TestCase
99
# PLAIN
1010
# ----------------------
1111

12-
def plain(*args, **kwargs, &block)
13-
Net::IMAP.authenticator("PLAIN", *args, **kwargs, &block)
14-
end
12+
def plain(...) Net::IMAP.authenticator("PLAIN", ...) end
1513

1614
def test_plain_authenticator_matches_mechanism
1715
assert_kind_of(Net::IMAP::PlainAuthenticator, plain("user", "pass"))
1816
end
1917

18+
def test_plain_supports_initial_response
19+
assert plain("foo", "bar").initial_response?
20+
assert Net::IMAP::SASL.initial_response?(plain("foo", "bar"))
21+
end
22+
2023
def test_plain_response
2124
assert_equal("\0authc\0passwd", plain("authc", "passwd").process(nil))
2225
assert_equal("authz\0user\0pass",
@@ -33,13 +36,24 @@ def test_plain_no_null_chars
3336
# XOAUTH2
3437
# ----------------------
3538

39+
def xoauth2(...) Net::IMAP.authenticator("XOAUTH2", ...) end
40+
41+
def test_xoauth2_authenticator_matches_mechanism
42+
assert_kind_of(Net::IMAP::XOauth2Authenticator, xoauth2("user", "pass"))
43+
end
44+
3645
def test_xoauth2
3746
assert_equal(
3847
"user=username\1auth=Bearer token\1\1",
39-
Net::IMAP::XOauth2Authenticator.new("username", "token").process(nil)
48+
xoauth2("username", "token").process(nil)
4049
)
4150
end
4251

52+
def test_xoauth2_supports_initial_response
53+
assert xoauth2("foo", "bar").initial_response?
54+
assert Net::IMAP::SASL.initial_response?(xoauth2("foo", "bar"))
55+
end
56+
4357
# ----------------------
4458
# LOGIN (obsolete)
4559
# ----------------------
@@ -54,6 +68,10 @@ def test_login_authenticator_matches_mechanism
5468
assert_kind_of(Net::IMAP::LoginAuthenticator, login("n", "p"))
5569
end
5670

71+
def test_login_does_not_support_initial_response
72+
refute Net::IMAP::SASL.initial_response?(login("foo", "bar"))
73+
end
74+
5775
def test_login_authenticator_deprecated
5876
assert_warn(/LOGIN.+deprecated.+PLAIN/) do
5977
Net::IMAP.authenticator("LOGIN", "user", "pass")
@@ -80,6 +98,10 @@ def test_cram_md5_authenticator_matches_mechanism
8098
assert_kind_of(Net::IMAP::CramMD5Authenticator, cram_md5("n", "p"))
8199
end
82100

101+
def test_cram_md5_does_not_support_initial_response
102+
refute Net::IMAP::SASL.initial_response?(cram_md5("foo", "bar"))
103+
end
104+
83105
def test_cram_md5_authenticator_deprecated
84106
assert_warn(/CRAM-MD5.+deprecated./) do
85107
Net::IMAP.authenticator("CRAM-MD5", "user", "pass")
@@ -112,6 +134,10 @@ def test_digest_md5_authenticator_deprecated
112134
end
113135
end
114136

137+
def test_digest_md5_does_not_support_initial_response
138+
refute Net::IMAP::SASL.initial_response?(digest_md5("foo", "bar"))
139+
end
140+
115141
def test_digest_md5_authenticator
116142
auth = digest_md5("cid", "password", "zid")
117143
assert_match(

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)