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 9, 2023
1 parent 55b1051 commit 51b12d6
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 10 deletions.
14 changes: 11 additions & 3 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -670,8 +670,9 @@ class IMAP < Protocol
"UTF8=ONLY" => "UTF8=ACCEPT",
}.freeze

autoload :SASL, File.expand_path("imap/sasl", __dir__)
autoload :StringPrep, File.expand_path("imap/stringprep", __dir__)
autoload :SASL, File.expand_path("imap/sasl", __dir__)
autoload :SASLAdapter, File.expand_path("imap/sasl_adapter", __dir__)
autoload :StringPrep, File.expand_path("imap/stringprep", __dir__)

include MonitorMixin
if defined?(OpenSSL::SSL)
Expand Down Expand Up @@ -1142,7 +1143,10 @@ def starttls(**options)
end

# :call-seq:
# authenticate(mechanism, *, sasl_ir: true, **, &) -> ok_resp
# authenticate(mechanism, *,
# sasl_ir: true,
# registry: Net::IMAP::SASL.authenticators,
# **, &) -> 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
Expand Down Expand Up @@ -2746,6 +2750,10 @@ def start_tls_session
end
end

def sasl_adapter
SASLAdapter.new(self, &method(:send_command_with_continuations))
end

#--
# We could get the saslprep method by extending the SASLprep module
# directly. It's done indirectly, so SASLprep can be lazily autoloaded,
Expand Down
10 changes: 8 additions & 2 deletions lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ def initialize(response, message = "authentication ended prematurely")
autoload :BidiStringError, sasl_stringprep_rb

sasl_dir = File.expand_path("sasl", __dir__)
autoload :AuthenticationExchange, "#{sasl_dir}/authentication_exchange"
autoload :ClientAdapter, "#{sasl_dir}/client_adapter"
autoload :ProtocolAdapters, "#{sasl_dir}/protocol_adapters"

autoload :Authenticators, "#{sasl_dir}/authenticators"
autoload :GS2Header, "#{sasl_dir}/gs2_header"
autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm"
Expand All @@ -155,8 +159,10 @@ def initialize(response, message = "authentication ended prematurely")
# Returns the default global SASL::Authenticators instance.
def self.authenticators; @authenticators ||= Authenticators.new end

# Delegates to ::authenticators. See Authenticators#authenticator.
def self.authenticator(...) authenticators.authenticator(...) end
# Delegates to <tt>registry.new</tt> See Authenticators#new.
def self.authenticator(*args, registry: authenticators, **kwargs, &block)
registry.new(*args, **kwargs, &block)
end

# Delegates to ::authenticators. See Authenticators#add_authenticator.
def self.add_authenticator(...) authenticators.add_authenticator(...) end
Expand Down
107 changes: 107 additions & 0 deletions lib/net/imap/sasl/authentication_exchange.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# frozen_string_literal: true

module Net
class IMAP
module SASL

# This API is *experimental*, and may change.
#
# 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.
#
# Create an AuthenticationExchange from a client adapter and a mechanism
# authenticator:
# def authenticate(mechanism, ...)
# authenticator = SASL.authenticator(mechanism, ...)
# SASL::AuthenticationExchange.new(
# sasl_adapter, mechanism, authenticator
# ).authenticate
# end
#
# private
#
# def sasl_adapter = MyClientAdapter.new(self, &method(:send_command))
#
# Or delegate creation of the authenticator to ::build:
# def authenticate(...)
# SASL::AuthenticationExchange.build(sasl_adapter, ...)
# .authenticate
# end
#
# As a convenience, ::authenticate combines ::build and #authenticate:
# def authenticate(...)
# SASL::AuthenticationExchange.authenticate(sasl_adapter, ...)
# end
#
# Likewise, ClientAdapter#authenticate delegates to #authenticate:
# def authenticate(...) = sasl_adapter.authenticate(...)
#
class AuthenticationExchange
# Convenience method for <tt>build(...).authenticate</tt>
def self.authenticate(...) build(...).authenticate end

# Use +registry+ to override the global Authenticators registry.
def self.build(client, mechanism, *args, sasl_ir: true, **kwargs, &block)
authenticator = SASL.authenticator(mechanism, *args, **kwargs, &block)
new(client, mechanism, authenticator, sasl_ir: sasl_ir)
end

attr_reader :mechanism, :authenticator

def initialize(client, mechanism, authenticator, sasl_ir: true)
@client = client
@mechanism = -mechanism.to_s.upcase.tr(?_, ?-)
@authenticator = authenticator
@sasl_ir = sasl_ir
@processed = false
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
client.run_command(mechanism, initial_response) { process _1 }
.tap { raise AuthenticationIncomplete, _1 unless done? }
rescue *client.response_errors
raise # but don't drop the connection
rescue
client.drop_connection
raise
rescue Exception # rubocop:disable Lint/RescueException
client.drop_connection!
raise
end

def send_initial_response?
@sasl_ir &&
authenticator.respond_to?(:initial_response?) &&
authenticator.initial_response? &&
client.sasl_ir_capable? &&
client.auth_capable?(mechanism)
end

def done?
authenticator.respond_to?(:done?) ? authenticator.done? : @processed
end

private

attr_reader :client

def initial_response
return unless send_initial_response?
client.encode_ir authenticator.process nil
end

def process(challenge)
client.encode authenticator.process client.decode challenge
ensure
@processed = true
end

end
end
end
end
11 changes: 6 additions & 5 deletions lib/net/imap/sasl/authenticators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def names; @authenticators.keys end
# lazily loaded from <tt>Net::IMAP::SASL::#{name}Authenticator</tt> (case is
# preserved and non-alphanumeric characters are removed..
def add_authenticator(name, authenticator = nil)
key = name.upcase.to_sym
key = -name.to_s.upcase.tr(?_, ?-)
authenticator ||= begin
class_name = "#{name.gsub(/[^a-zA-Z0-9]/, "")}Authenticator".to_sym
auth_class = nil
Expand All @@ -79,12 +79,12 @@ def add_authenticator(name, authenticator = nil)

# Removes the authenticator registered for +name+
def remove_authenticator(name)
key = name.upcase.to_sym
key = -name.to_s.upcase.tr(?_, ?-)
@authenticators.delete(key)
end

def mechanism?(name)
key = name.upcase.to_sym
key = -name.to_s.upcase.tr(?_, ?-)
@authenticators.key?(key)
end

Expand All @@ -105,8 +105,9 @@ def mechanism?(name)
# only. Protocol client users should see refer to their client's
# documentation, e.g. Net::IMAP#authenticate.
def authenticator(mechanism, ...)
auth = @authenticators.fetch(mechanism.upcase.to_sym) do
raise ArgumentError, 'unknown auth type - "%s"' % mechanism
key = -mechanism.to_s.upcase.tr(?_, ?-)
auth = @authenticators.fetch(key) do
raise ArgumentError, 'unknown auth type - "%s"' % key
end
auth.respond_to?(:new) ? auth.new(...) : auth.call(...)
end
Expand Down
72 changes: 72 additions & 0 deletions lib/net/imap/sasl/client_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

module Net
class IMAP
module SASL

# This API is *experimental*, and may change.
#
# 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.
#
# Although the default implementations _may_ be sufficient, subclasses
# will probably need to override some methods. Additionally, subclasses
# may need to include a protocol adapter mixin, if the default
# ProtocolAdapters::Generic isn't sufficient.
class ClientAdapter
include ProtocolAdapters::Generic

attr_reader :client, :command_proc

# +command_proc+ can used to avoid exposing private methods on #client.
# It should run a command with the arguments sent to it, yield each
# continuation payload, respond to the server with the result of each
# yield, and return the result. Non-successful results *MUST* raise an
# exception. Exceptions in the block *MUST* cause the command to fail.
#
# Subclasses that override #run_command may use #command_proc for
# other purposes.
def initialize(client, &command_proc)
@client, @command_proc = client, command_proc
end

# Delegates to AuthenticationExchange.authenticate.
def authenticate(...) AuthenticationExchange.authenticate(self, ...) end

# Do the protocol and server both support an initial response?
def sasl_ir_capable?; client.sasl_ir_capable? end

# Does the server advertise support for the mechanism?
def auth_capable?(mechanism); client.auth_capable?(mechanism) end

# Runs the authenticate command with +mechanism+ and +initial_response+.
# When +initial_response+ is nil, an initial response must NOT be sent.
#
# 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.
#
# Subclasses that override this may use #command_proc differently.
def run_command(mechanism, initial_response = nil, &block)
command_proc or raise Error, "initialize with block or override"
args = [command_name, mechanism, initial_response].compact
command_proc.call(*args, &block)
end

# Returns an array of server responses errors raised by run_command.
# Exceptions in this array won't drop the connection.
def response_errors; [] end

# Drop the connection gracefully.
def drop_connection; client.drop_connection end

# Drop the connection abruptly.
def drop_connection!; client.drop_connection! end
end
end
end
end
45 changes: 45 additions & 0 deletions lib/net/imap/sasl/protocol_adapters.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

module Net
class IMAP
module SASL

module ProtocolAdapters
# This API is experimental, and may change.
module Generic
def command_name; "AUTHENTICATE" end
def service; raise "Implement in subclass or module" end
def host; client.host end
def port; client.port 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
end

# See RFC-3501 (IMAP4rev1), RFC-4959 (SASL-IR capability),
# and RFC-9051 (IMAP4rev2).
module IMAP
include Generic
def service; "imap" end
end

# See RFC-4954 (AUTH capability).
module SMTP
include Generic
def command_name; "AUTH" end
def service; "smtp" end
end

# See RFC-5034 (SASL capability).
module POP
include Generic
def command_name; "AUTH" end
def service; "pop" end
end

end

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

module Net
class IMAP

# Experimental
class SASLAdapter < SASL::ClientAdapter
include SASL::ProtocolAdapters::IMAP

RESPONSE_ERRORS = [NoResponseError, BadResponseError, ByeResponseError]
.freeze

def response_errors; RESPONSE_ERRORS end
def sasl_ir_capable?; client.capable?("SASL-IR") end
def auth_capable?(mechanism); client.auth_capable?(mechanism) end
def drop_connection; client.logout! end
def drop_connection!; client.disconnect end
end

end
end

0 comments on commit 51b12d6

Please sign in to comment.