Skip to content

Commit

Permalink
✨ Add very basic SASL-IR support to #authenticate
Browse files Browse the repository at this point in the history
I'd like the global default for `:sasl_ir` value to be configurable, but
that can come in another PR.
  • Loading branch information
nevans committed Jul 28, 2023
1 parent b8f2986 commit 637c62e
Show file tree
Hide file tree
Showing 8 changed files with 135 additions and 18 deletions.
44 changes: 29 additions & 15 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -983,21 +983,24 @@ 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 the server #capabilities,
# <tt>sasl_ir: :auto</tt> sends an "initial response" to save a round trip.
# The current default is +false+ but a future version will default to
# +:auto+. Use <tt>sasl_ir: true</tt> to send an initial response without
# checking #capabilities.
#
# All other arguments are forwarded to the authenticator for the requested
# mechanism. The listed call signatures are suggestions. <em>The
# documentation for each individual mechanism must be consulted for its
# specific parameters.</em>
# mechanism. <em>The documentation for each individual mechanism must be
# consulted for its specific parameters.</em>
#
# An exception Net::IMAP::NoResponseError is raised if authentication fails.
#
Expand Down Expand Up @@ -1053,14 +1056,25 @@ def starttls(options = {}, verify = true)
# If the TaggedResponse to #authenticate includes updated capabilities, they
# will be cached.
#
def authenticate(mechanism, ...)
authenticator = self.class.authenticator(mechanism, ...)
send_command("AUTHENTICATE", mechanism) do |resp|
def authenticate(mechanism, *creds, sasl_ir: false, **props, &callback)
[nil, true, false, :auto].include?(sasl_ir) or
raise ArgumentError, "sasl_ir must be boolean or :auto"
authenticator = self.class.authenticator(mechanism,
*creds,
**props,
&callback)
cmdargs = ["AUTHENTICATE", mechanism]
sasl_ir = capable?("SASL-IR") if [nil, :auto].include?(sasl_ir)
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 }
Expand Down
2 changes: 2 additions & 0 deletions lib/net/imap/authenticators/plain.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion test/net/imap/fake_server/command_reader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
10 changes: 8 additions & 2 deletions test/net/imap/fake_server/command_router.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions test/net/imap/fake_server/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Configuration
encrypted_login: true,
cleartext_auth: false,
sasl_mechanisms: %i[PLAIN].freeze,
sasl_ir: false,

rev1: true,
rev2: false,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -104,13 +106,15 @@ 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

def capabilities_pre_auth
capa = basic_capabilities
capa << "LOGINDISABLED" unless encrypted_login?
capa.concat auth_capabilities
capa << "SASL-IR" if sasl_ir?
capa
end

Expand Down
85 changes: 85 additions & 0 deletions test/net/imap/test_imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
2 changes: 2 additions & 0 deletions test/net/imap/test_imap_capabilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit 637c62e

Please sign in to comment.