Skip to content

Commit c10518b

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 a9459de commit c10518b

File tree

4 files changed

+150
-20
lines changed

4 files changed

+150
-20
lines changed

lib/net/imap.rb

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,27 +1247,13 @@ def starttls(**options)
12471247
# Previously cached #capabilities will be cleared when this method
12481248
# completes. If the TaggedResponse to #authenticate includes updated
12491249
# capabilities, they will be cached.
1250-
def authenticate(mechanism, *creds, sasl_ir: true, **props, &callback)
1250+
def authenticate(mechanism, *args, sasl_ir: true, **kwargs, &block)
12511251
mechanism = mechanism.to_s.tr("_", "-").upcase
1252-
authenticator = SASL.authenticator(mechanism, *creds, **props, &callback)
1253-
cmdargs = ["AUTHENTICATE", mechanism]
1254-
if sasl_ir && capable?("SASL-IR") && auth_capable?(mechanism) &&
1255-
authenticator.respond_to?(:initial_response?) &&
1256-
authenticator.initial_response?
1257-
response = authenticator.process(nil)
1258-
cmdargs << (response.empty? ? "=" : [response].pack("m0"))
1259-
end
1260-
result = send_command_with_continuations(*cmdargs) {|data|
1261-
challenge = data.unpack1("m")
1262-
response = authenticator.process challenge
1263-
[response].pack("m0")
1264-
}
1265-
if authenticator.respond_to?(:done?) && !authenticator.done?
1266-
logout!
1267-
raise SASL::AuthenticationIncomplete, result
1268-
end
1269-
@capabilities = capabilities_from_resp_code result
1270-
result
1252+
authenticator = SASL.authenticator(mechanism, *args, **kwargs, &block)
1253+
SASL::IMAPAdapter.authenticate(self, mechanism, authenticator,
1254+
sasl_ir: sasl_ir,
1255+
&method(:send_command_with_continuations))
1256+
.tap { @capabilities = capabilities_from_resp_code _1 }
12711257
end
12721258

12731259
# Sends a {LOGIN command [IMAP4rev1 §6.2.3]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.3]

lib/net/imap/sasl.rb

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

137137
sasl_dir = File.expand_path("sasl", __dir__)
138+
autoload :ClientAdapter, "#{sasl_dir}/client_adapter"
139+
autoload :IMAPAdapter, "#{sasl_dir}/imap_adapter"
140+
138141
autoload :Authenticators, "#{sasl_dir}/authenticators"
139142
autoload :GS2Header, "#{sasl_dir}/gs2_header"
140143
autoload :ScramAlgorithm, "#{sasl_dir}/scram_algorithm"

lib/net/imap/sasl/client_adapter.rb

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP
5+
module SASL
6+
7+
# This API is *experimental*.
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+
# An abstract base class for implementing a SASL authentication exchange.
14+
# Different clients will each have their own adapter subclass, overridden
15+
# to match their needs. Methods to override are documented as protected.
16+
class ClientAdapter
17+
# Subclasses must redefine this if their command isn't "AUTHENTICATE".
18+
COMMAND_NAME = "AUTHENTICATE"
19+
20+
# Subclasses should redefine this to include all server responses errors
21+
# raised by send_command_with_continuations.
22+
RESPONSE_ERRORS = [].freeze
23+
24+
# Convenience method for <tt>new(...).authenticate</tt>
25+
def self.authenticate(...) new(...).authenticate end
26+
27+
attr_reader :client, :mechanism, :authenticator
28+
29+
# Can be supplied by +client+, to avoid exposing private methods.
30+
attr_reader :command_proc
31+
32+
# When +sasl_ir+ is false, sending an initial response is prohibited.
33+
# +command_proc+ can used to avoid exposing private methods on #client.
34+
def initialize(client, mechanism, authenticator, sasl_ir: true,
35+
&command_proc)
36+
@client = client
37+
@mechanism = mechanism
38+
@authenticator = authenticator
39+
@sasl_ir = sasl_ir
40+
@command_proc = command_proc
41+
end
42+
43+
# Call #authenticate to execute an authentication exchange for #client
44+
# using #authenticator. Authentication failures will raise an
45+
# exception. Any exceptions other than those in RESPONSE_ERRORS will
46+
# drop the connection.
47+
def authenticate
48+
response = process_ir if send_initial_response?
49+
args = authenticate_command_args(response)
50+
send_command_with_continuations(*args) { process _1 }
51+
.tap { raise AuthenticationIncomplete, _1 unless done? }
52+
rescue *self.class::RESPONSE_ERRORS => ex
53+
raise transform_exception(ex)
54+
rescue => ex
55+
drop_connection
56+
raise transform_exception(ex)
57+
rescue Exception
58+
drop_connection!
59+
raise
60+
end
61+
62+
protected
63+
64+
# Override if the arguments for send_command_with_continuations aren't
65+
# simply <tt>(COMMAND_NAME, mechanism, initial_response = nil)</tt>.
66+
def authenticate_command_args(initial_response = nil)
67+
[self.class::COMMAND_NAME, mechanism, initial_response].compact
68+
end
69+
70+
def encode_ir(string) string.empty? ? "=" : encode(string) end
71+
def encode(string) [string].pack("m0") end
72+
def decode(string) string.unpack1("m0") end
73+
def cancel_response; "*" end
74+
75+
# Override if the protocol always/never supports SASL-IR, the capability
76+
# isn't named +SASL-IR+, or #client doesn't respond to +capable?+.
77+
def supports_initial_response?; client.capable?("SASL-IR") end
78+
79+
# Override if #client doesn't respond to +auth_capable?+.
80+
def supports_mechanism?; client.auth_capable?(mechanism) end
81+
82+
# Runs the authenticate_command_args, yields each continuation payload,
83+
# responds to the server with the result of each yield, and returns the
84+
# result. Non-successful results *MUST* raise an exception. Exceptions
85+
# in the block *MUST* cause the command to fail.
86+
#
87+
# The default simply forwards all arguments to command_proc.
88+
# Subclasses that override this may use command_proc differently.
89+
def send_command_with_continuations(...)
90+
command_proc or raise Error, "initialize with block or override"
91+
command_proc.call(...)
92+
end
93+
94+
# Override to logout and disconnect the connection gracefully.
95+
def drop_connection; client.disconnect end
96+
97+
# Override to drop the connection abruptly.
98+
def drop_connection!; client.disconnect end
99+
100+
# Override to transform any StandardError to a different exception.
101+
def transform_exception(exception) exception end
102+
103+
private
104+
105+
# Subclasses shouldn't override the following
106+
107+
def send_initial_response?
108+
@sasl_ir &&
109+
authenticator.respond_to?(:initial_response?) &&
110+
authenticator.initial_response? &&
111+
supports_initial_response? &&
112+
supports_mechanism?
113+
end
114+
115+
def process_ir; encode_ir authenticator.process nil end
116+
def process(data) encode authenticator.process decode data end
117+
118+
def done?; !authenticator.respond_to?(:done?) || authenticator.done? end
119+
120+
end
121+
end
122+
end
123+
end

lib/net/imap/sasl/imap_adapter.rb

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

0 commit comments

Comments
 (0)