Skip to content

Commit 51b12d6

Browse files
committed
⚗️ ♻️ Add experimental SASL::ClientAdapter
_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`.
1 parent 55b1051 commit 51b12d6

File tree

7 files changed

+270
-10
lines changed

7 files changed

+270
-10
lines changed

lib/net/imap.rb

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -670,8 +670,9 @@ class IMAP < Protocol
670670
"UTF8=ONLY" => "UTF8=ACCEPT",
671671
}.freeze
672672

673-
autoload :SASL, File.expand_path("imap/sasl", __dir__)
674-
autoload :StringPrep, File.expand_path("imap/stringprep", __dir__)
673+
autoload :SASL, File.expand_path("imap/sasl", __dir__)
674+
autoload :SASLAdapter, File.expand_path("imap/sasl_adapter", __dir__)
675+
autoload :StringPrep, File.expand_path("imap/stringprep", __dir__)
675676

676677
include MonitorMixin
677678
if defined?(OpenSSL::SSL)
@@ -1142,7 +1143,10 @@ def starttls(**options)
11421143
end
11431144

11441145
# :call-seq:
1145-
# authenticate(mechanism, *, sasl_ir: true, **, &) -> ok_resp
1146+
# authenticate(mechanism, *,
1147+
# sasl_ir: true,
1148+
# registry: Net::IMAP::SASL.authenticators,
1149+
# **, &) -> ok_resp
11461150
#
11471151
# Sends an {AUTHENTICATE command [IMAP4rev1 §6.2.2]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.2]
11481152
# to authenticate the client. If successful, the connection enters the
@@ -2746,6 +2750,10 @@ def start_tls_session
27462750
end
27472751
end
27482752

2753+
def sasl_adapter
2754+
SASLAdapter.new(self, &method(:send_command_with_continuations))
2755+
end
2756+
27492757
#--
27502758
# We could get the saslprep method by extending the SASLprep module
27512759
# directly. It's done indirectly, so SASLprep can be lazily autoloaded,

lib/net/imap/sasl.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ def initialize(response, message = "authentication ended prematurely")
135135
autoload :BidiStringError, sasl_stringprep_rb
136136

137137
sasl_dir = File.expand_path("sasl", __dir__)
138+
autoload :AuthenticationExchange, "#{sasl_dir}/authentication_exchange"
139+
autoload :ClientAdapter, "#{sasl_dir}/client_adapter"
140+
autoload :ProtocolAdapters, "#{sasl_dir}/protocol_adapters"
141+
138142
autoload :Authenticators, "#{sasl_dir}/authenticators"
139143
autoload :GS2Header, "#{sasl_dir}/gs2_header"
140144
autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm"
@@ -155,8 +159,10 @@ def initialize(response, message = "authentication ended prematurely")
155159
# Returns the default global SASL::Authenticators instance.
156160
def self.authenticators; @authenticators ||= Authenticators.new end
157161

158-
# Delegates to ::authenticators. See Authenticators#authenticator.
159-
def self.authenticator(...) authenticators.authenticator(...) end
162+
# Delegates to <tt>registry.new</tt> See Authenticators#new.
163+
def self.authenticator(*args, registry: authenticators, **kwargs, &block)
164+
registry.new(*args, **kwargs, &block)
165+
end
160166

161167
# Delegates to ::authenticators. See Authenticators#add_authenticator.
162168
def self.add_authenticator(...) authenticators.add_authenticator(...) end
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP
5+
module SASL
6+
7+
# This API is *experimental*, and may change.
8+
#
9+
# TODO: catch exceptions in #process and send #cancel_response.
10+
# TODO: raise an error if the command succeeds after being canceled.
11+
# TODO: use with more clients, to verify the API can accommodate them.
12+
#
13+
# Create an AuthenticationExchange from a client adapter and a mechanism
14+
# authenticator:
15+
# def authenticate(mechanism, ...)
16+
# authenticator = SASL.authenticator(mechanism, ...)
17+
# SASL::AuthenticationExchange.new(
18+
# sasl_adapter, mechanism, authenticator
19+
# ).authenticate
20+
# end
21+
#
22+
# private
23+
#
24+
# def sasl_adapter = MyClientAdapter.new(self, &method(:send_command))
25+
#
26+
# Or delegate creation of the authenticator to ::build:
27+
# def authenticate(...)
28+
# SASL::AuthenticationExchange.build(sasl_adapter, ...)
29+
# .authenticate
30+
# end
31+
#
32+
# As a convenience, ::authenticate combines ::build and #authenticate:
33+
# def authenticate(...)
34+
# SASL::AuthenticationExchange.authenticate(sasl_adapter, ...)
35+
# end
36+
#
37+
# Likewise, ClientAdapter#authenticate delegates to #authenticate:
38+
# def authenticate(...) = sasl_adapter.authenticate(...)
39+
#
40+
class AuthenticationExchange
41+
# Convenience method for <tt>build(...).authenticate</tt>
42+
def self.authenticate(...) build(...).authenticate end
43+
44+
# Use +registry+ to override the global Authenticators registry.
45+
def self.build(client, mechanism, *args, sasl_ir: true, **kwargs, &block)
46+
authenticator = SASL.authenticator(mechanism, *args, **kwargs, &block)
47+
new(client, mechanism, authenticator, sasl_ir: sasl_ir)
48+
end
49+
50+
attr_reader :mechanism, :authenticator
51+
52+
def initialize(client, mechanism, authenticator, sasl_ir: true)
53+
@client = client
54+
@mechanism = -mechanism.to_s.upcase.tr(?_, ?-)
55+
@authenticator = authenticator
56+
@sasl_ir = sasl_ir
57+
@processed = false
58+
end
59+
60+
# Call #authenticate to execute an authentication exchange for #client
61+
# using #authenticator. Authentication failures will raise an
62+
# exception. Any exceptions other than those in RESPONSE_ERRORS will
63+
# drop the connection.
64+
def authenticate
65+
client.run_command(mechanism, initial_response) { process _1 }
66+
.tap { raise AuthenticationIncomplete, _1 unless done? }
67+
rescue *client.response_errors
68+
raise # but don't drop the connection
69+
rescue
70+
client.drop_connection
71+
raise
72+
rescue Exception # rubocop:disable Lint/RescueException
73+
client.drop_connection!
74+
raise
75+
end
76+
77+
def send_initial_response?
78+
@sasl_ir &&
79+
authenticator.respond_to?(:initial_response?) &&
80+
authenticator.initial_response? &&
81+
client.sasl_ir_capable? &&
82+
client.auth_capable?(mechanism)
83+
end
84+
85+
def done?
86+
authenticator.respond_to?(:done?) ? authenticator.done? : @processed
87+
end
88+
89+
private
90+
91+
attr_reader :client
92+
93+
def initial_response
94+
return unless send_initial_response?
95+
client.encode_ir authenticator.process nil
96+
end
97+
98+
def process(challenge)
99+
client.encode authenticator.process client.decode challenge
100+
ensure
101+
@processed = true
102+
end
103+
104+
end
105+
end
106+
end
107+
end

lib/net/imap/sasl/authenticators.rb

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def names; @authenticators.keys end
6565
# lazily loaded from <tt>Net::IMAP::SASL::#{name}Authenticator</tt> (case is
6666
# preserved and non-alphanumeric characters are removed..
6767
def add_authenticator(name, authenticator = nil)
68-
key = name.upcase.to_sym
68+
key = -name.to_s.upcase.tr(?_, ?-)
6969
authenticator ||= begin
7070
class_name = "#{name.gsub(/[^a-zA-Z0-9]/, "")}Authenticator".to_sym
7171
auth_class = nil
@@ -79,12 +79,12 @@ def add_authenticator(name, authenticator = nil)
7979

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

8686
def mechanism?(name)
87-
key = name.upcase.to_sym
87+
key = -name.to_s.upcase.tr(?_, ?-)
8888
@authenticators.key?(key)
8989
end
9090

@@ -105,8 +105,9 @@ def mechanism?(name)
105105
# only. Protocol client users should see refer to their client's
106106
# documentation, e.g. Net::IMAP#authenticate.
107107
def authenticator(mechanism, ...)
108-
auth = @authenticators.fetch(mechanism.upcase.to_sym) do
109-
raise ArgumentError, 'unknown auth type - "%s"' % mechanism
108+
key = -mechanism.to_s.upcase.tr(?_, ?-)
109+
auth = @authenticators.fetch(key) do
110+
raise ArgumentError, 'unknown auth type - "%s"' % key
110111
end
111112
auth.respond_to?(:new) ? auth.new(...) : auth.call(...)
112113
end

lib/net/imap/sasl/client_adapter.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP
5+
module SASL
6+
7+
# This API is *experimental*, and may change.
8+
#
9+
# TODO: use with more clients, to verify the API can accommodate them.
10+
#
11+
# An abstract base class for implementing a SASL authentication exchange.
12+
# Different clients will each have their own adapter subclass, overridden
13+
# to match their needs.
14+
#
15+
# Although the default implementations _may_ be sufficient, subclasses
16+
# will probably need to override some methods. Additionally, subclasses
17+
# may need to include a protocol adapter mixin, if the default
18+
# ProtocolAdapters::Generic isn't sufficient.
19+
class ClientAdapter
20+
include ProtocolAdapters::Generic
21+
22+
attr_reader :client, :command_proc
23+
24+
# +command_proc+ can used to avoid exposing private methods on #client.
25+
# It should run a command with the arguments sent to it, yield each
26+
# continuation payload, respond to the server with the result of each
27+
# yield, and return the result. Non-successful results *MUST* raise an
28+
# exception. Exceptions in the block *MUST* cause the command to fail.
29+
#
30+
# Subclasses that override #run_command may use #command_proc for
31+
# other purposes.
32+
def initialize(client, &command_proc)
33+
@client, @command_proc = client, command_proc
34+
end
35+
36+
# Delegates to AuthenticationExchange.authenticate.
37+
def authenticate(...) AuthenticationExchange.authenticate(self, ...) end
38+
39+
# Do the protocol and server both support an initial response?
40+
def sasl_ir_capable?; client.sasl_ir_capable? end
41+
42+
# Does the server advertise support for the mechanism?
43+
def auth_capable?(mechanism); client.auth_capable?(mechanism) end
44+
45+
# Runs the authenticate command with +mechanism+ and +initial_response+.
46+
# When +initial_response+ is nil, an initial response must NOT be sent.
47+
#
48+
# Yields each continuation payload, responds to the server with the
49+
# result of each yield, and returns the result. Non-successful results
50+
# *MUST* raise an exception. Exceptions in the block *MUST* cause the
51+
# command to fail.
52+
#
53+
# Subclasses that override this may use #command_proc differently.
54+
def run_command(mechanism, initial_response = nil, &block)
55+
command_proc or raise Error, "initialize with block or override"
56+
args = [command_name, mechanism, initial_response].compact
57+
command_proc.call(*args, &block)
58+
end
59+
60+
# Returns an array of server responses errors raised by run_command.
61+
# Exceptions in this array won't drop the connection.
62+
def response_errors; [] end
63+
64+
# Drop the connection gracefully.
65+
def drop_connection; client.drop_connection end
66+
67+
# Drop the connection abruptly.
68+
def drop_connection!; client.drop_connection! end
69+
end
70+
end
71+
end
72+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP
5+
module SASL
6+
7+
module ProtocolAdapters
8+
# This API is experimental, and may change.
9+
module Generic
10+
def command_name; "AUTHENTICATE" end
11+
def service; raise "Implement in subclass or module" end
12+
def host; client.host end
13+
def port; client.port end
14+
def encode_ir(string) string.empty? ? "=" : encode(string) end
15+
def encode(string) [string].pack("m0") end
16+
def decode(string) string.unpack1("m0") end
17+
def cancel_response; "*" end
18+
end
19+
20+
# See RFC-3501 (IMAP4rev1), RFC-4959 (SASL-IR capability),
21+
# and RFC-9051 (IMAP4rev2).
22+
module IMAP
23+
include Generic
24+
def service; "imap" end
25+
end
26+
27+
# See RFC-4954 (AUTH capability).
28+
module SMTP
29+
include Generic
30+
def command_name; "AUTH" end
31+
def service; "smtp" end
32+
end
33+
34+
# See RFC-5034 (SASL capability).
35+
module POP
36+
include Generic
37+
def command_name; "AUTH" end
38+
def service; "pop" end
39+
end
40+
41+
end
42+
43+
end
44+
end
45+
end

lib/net/imap/sasl_adapter.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP
5+
6+
# Experimental
7+
class SASLAdapter < SASL::ClientAdapter
8+
include SASL::ProtocolAdapters::IMAP
9+
10+
RESPONSE_ERRORS = [NoResponseError, BadResponseError, ByeResponseError]
11+
.freeze
12+
13+
def response_errors; RESPONSE_ERRORS end
14+
def sasl_ir_capable?; client.capable?("SASL-IR") end
15+
def auth_capable?(mechanism); client.auth_capable?(mechanism) end
16+
def drop_connection; client.logout! end
17+
def drop_connection!; client.disconnect end
18+
end
19+
20+
end
21+
end

0 commit comments

Comments
 (0)