Skip to content

Commit

Permalink
🚧♻️ Add experimental SASL::ClientAdapter
Browse files Browse the repository at this point in the history
_The API is **experimental.**_

TODO: catch exceptions in #process and send #cancel_string.
TODO: raise an error if the command succeeds after being canceled.
TODO: use with more clients, to verify the API can accommodate them.

An abstract base class for executing a SASL authentication exchange for
a client.  Subclasses works as an adapter for a protocol and a client
implementation of that protocol.

Call `#authenticate` to execute an authentication exchange for `#client`
using `#authenticator`.  Authentication failures will raise an
exception.  Any exceptions other than those in RESPONSE_ERRORs will also
drop the connection.

Methods for subclasses to override are all documented as `protected`.
At the very least, subclasses must provide an override (or a block) for
`#send_command_with_continuations`.  Client-specific overrides may also
be needed for `RESPONSE_ERRORS`, `#supports_initial_response?`,
`#supports_mechanism?`, `#handle_incomplete`, or `#drop_connection`.
  • Loading branch information
nevans committed Oct 2, 2023
1 parent c3f7e7c commit c9d4e5b
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 20 deletions.
26 changes: 6 additions & 20 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1247,27 +1247,13 @@ def starttls(**options)
# 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: true, **props, &callback)
def authenticate(mechanism, *args, sasl_ir: true, **kwargs, &block)
mechanism = mechanism.to_s.tr("_", "-").upcase
authenticator = SASL.authenticator(mechanism, *creds, **props, &callback)
cmdargs = ["AUTHENTICATE", mechanism]
if sasl_ir && capable?("SASL-IR") && auth_capable?(mechanism) &&
authenticator.respond_to?(:initial_response?) &&
authenticator.initial_response?
response = authenticator.process(nil)
cmdargs << (response.empty? ? "=" : [response].pack("m0"))
end
result = send_command_with_continuations(*cmdargs) {|data|
challenge = data.unpack1("m")
response = authenticator.process challenge
[response].pack("m0")
}
if authenticator.respond_to?(:done?) && !authenticator.done?
logout!
raise SASL::AuthenticationIncomplete, result
end
@capabilities = capabilities_from_resp_code result
result
authenticator = SASL.authenticator(mechanism, *args, **kwargs, &block)
SASL::IMAPAdapter.authenticate(self, mechanism, authenticator,
sasl_ir: sasl_ir,
&method(:send_command_with_continuations))
.tap { @capabilities = capabilities_from_resp_code _1 }
end

# Sends a {LOGIN command [IMAP4rev1 §6.2.3]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.3]
Expand Down
3 changes: 3 additions & 0 deletions lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ def initialize(response, message = "authentication ended prematurely")
autoload :BidiStringError, sasl_stringprep_rb

sasl_dir = File.expand_path("sasl", __dir__)
autoload :ClientAdapter, "#{sasl_dir}/client_adapter"
autoload :IMAPAdapter, "#{sasl_dir}/imap_adapter"

autoload :Authenticators, "#{sasl_dir}/authenticators"
autoload :GS2Header, "#{sasl_dir}/gs2_header"
autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm"
Expand Down
123 changes: 123 additions & 0 deletions lib/net/imap/sasl/client_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# frozen_string_literal: true

module Net
class IMAP
module SASL

# This API is *experimental*.
#
# TODO: catch exceptions in #process and send #cancel_response.
# TODO: raise an error if the command succeeds after being canceled.
# TODO: use with more clients, to verify the API can accommodate them.
#
# An abstract base class for implementing a SASL authentication exchange.
# Different clients will each have their own adapter subclass, overridden
# to match their needs. Methods to override are documented as protected.
class ClientAdapter
# Subclasses must redefine this if their command isn't "AUTHENTICATE".
COMMAND_NAME = "AUTHENTICATE"

# Subclasses should redefine this to include all server responses errors
# raised by send_command_with_continuations.
RESPONSE_ERRORS = [].freeze

# Convenience method for <tt>new(...).authenticate</tt>
def self.authenticate(...) new(...).authenticate end

attr_reader :client, :mechanism, :authenticator

# Can be supplied by +client+, to avoid exposing private methods.
attr_reader :command_proc

# When +sasl_ir+ is false, sending an initial response is prohibited.
# +command_proc+ can used to avoid exposing private methods on #client.
def initialize(client, mechanism, authenticator, sasl_ir: true,
&command_proc)
@client = client
@mechanism = mechanism
@authenticator = authenticator
@sasl_ir = sasl_ir
@command_proc = command_proc
end

# Call #authenticate to execute an authentication exchange for #client
# using #authenticator. Authentication failures will raise an
# exception. Any exceptions other than those in RESPONSE_ERRORS will
# drop the connection.
def authenticate
response = process_ir if send_initial_response?
args = authenticate_command_args(response)
send_command_with_continuations(*args) { process _1 }
.tap { raise AuthenticationIncomplete, _1 unless done? }
rescue *self.class::RESPONSE_ERRORS => ex
raise transform_exception(ex)
rescue => ex
drop_connection
raise transform_exception(ex)
rescue Exception
drop_connection!
raise
end

protected

# Override if the arguments for send_command_with_continuations aren't
# simply <tt>(COMMAND_NAME, mechanism, initial_response = nil)</tt>.
def authenticate_command_args(initial_response = nil)
[self.class::COMMAND_NAME, mechanism, initial_response].compact
end

def encode_ir(string) string.empty? ? "=" : encode(string) end
def encode(string) [string].pack("m0") end
def decode(string) string.unpack1("m0") end
def cancel_response; "*" end

# Override if the protocol always/never supports SASL-IR, the capability
# isn't named +SASL-IR+, or #client doesn't respond to +capable?+.
def supports_initial_response?; client.capable?("SASL-IR") end

# Override if #client doesn't respond to +auth_capable?+.
def supports_mechanism?; client.auth_capable?(mechanism) end

# Runs the authenticate_command_args, yields each continuation payload,
# responds to the server with the result of each yield, and returns the
# result. Non-successful results *MUST* raise an exception. Exceptions
# in the block *MUST* cause the command to fail.
#
# The default simply forwards all arguments to command_proc.
# Subclasses that override this may use command_proc differently.
def send_command_with_continuations(...)
command_proc or raise Error, "initialize with block or override"
command_proc.call(...)
end

# Override to logout and disconnect the connection gracefully.
def drop_connection; client.disconnect end

# Override to drop the connection abruptly.
def drop_connection!; client.disconnect end

# Override to transform any StandardError to a different exception.
def transform_exception(exception) exception end

private

# Subclasses shouldn't override the following

def send_initial_response?
@sasl_ir &&
authenticator.respond_to?(:initial_response?) &&
authenticator.initial_response? &&
supports_initial_response? &&
supports_mechanism?
end

def process_ir; encode_ir authenticator.process nil end
def process(data) encode authenticator.process decode data end

def done?; !authenticator.respond_to?(:done?) || authenticator.done? end

end
end
end
end
18 changes: 18 additions & 0 deletions lib/net/imap/sasl/imap_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

module Net
class IMAP
module SASL

# Experimental
class IMAPAdapter < ClientAdapter
RESPONSE_ERRORS = [
NoResponseError, BadResponseError, ByeResponseError
].freeze
def supports_initial_response?; client.capable?("SASL-IR") end
def supports_mechanism?; client.auth_capable?(mechanism) end
def drop_connection; client.logout! end
end
end
end
end

0 comments on commit c9d4e5b

Please sign in to comment.