Skip to content

Commit 5196044

Browse files
committed
🚧 Handle cancellations more carefully...
1 parent 0ac1137 commit 5196044

File tree

3 files changed

+82
-15
lines changed

3 files changed

+82
-15
lines changed

lib/net/imap/sasl.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,15 @@ module SASL
103103

104104
# Indicates an authentication exchange that will be or has been canceled
105105
# by the client, not due to any error or failure during processing.
106-
AuthenticationCanceled = Class.new(Error)
106+
class AuthenticationCanceled < Error
107+
# The error response from the server
108+
attr_reader :response
109+
110+
def initialize(message = "authentication canceled", response: nil)
111+
super(message)
112+
@response = response
113+
end
114+
end
107115

108116
# Indicates an error when processing a server challenge, e.g: an invalid
109117
# or unparsable challenge. An underlying exception may be available as

lib/net/imap/sasl/authentication_exchange.rb

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ def self.build(client, mechanism, *args, sasl_ir: true, **kwargs, &block)
8282
# An exception that has been raised by <tt>authenticator.process</tt>.
8383
attr_reader :process_error
8484

85+
# An exception that represents an error response from the server.
86+
attr_reader :response_error
87+
8588
def initialize(client, mechanism, authenticator, sasl_ir: true)
8689
@client = client
8790
@mechanism = Authenticators.normalize_name(mechanism)
@@ -103,9 +106,11 @@ def initialize(client, mechanism, authenticator, sasl_ir: true)
103106
# Unfortunately, the original error will not be the +#cause+ for the
104107
# client error. But it will be available on #process_error.
105108
def authenticate
106-
client.run_command(mechanism, initial_response) { process _1 }
107-
.tap { raise process_error if process_error }
108-
.tap { raise AuthenticationIncomplete, _1 unless done? }
109+
handle_cancellation do
110+
client.run_command(mechanism, initial_response) { process _1 }
111+
.tap { raise process_error if process_error }
112+
.tap { raise AuthenticationIncomplete, _1 unless done? }
113+
end
109114
rescue AuthenticationCanceled, *client.response_errors
110115
raise # but don't drop the connection
111116
rescue
@@ -141,11 +146,53 @@ def process(challenge)
141146
@processed = true
142147
return client.cancel_response if process_error
143148
client.encode authenticator.process client.decode challenge
144-
rescue => process_error
145-
@process_error = process_error
149+
rescue AuthenticationCanceled => error
150+
@process_error = error
151+
client.cancel_response
152+
rescue => error
153+
@process_error = begin
154+
raise AuthenticationError, "error while processing server challenge"
155+
rescue
156+
$!
157+
end
146158
client.cancel_response
147159
end
148160

161+
# | process | response | => result |
162+
# |---------|----------|------------------------------------------|
163+
# | success | success | success |
164+
# | success | error | reraise response error |
165+
# | error | success | raise incomplete error (cause = process) |
166+
# | error | error | raise canceled error (cause = process) |
167+
def handle_cancellation
168+
result = begin
169+
yield
170+
rescue *client.response_errors => error
171+
@response_error = error
172+
raise unless process_error
173+
end
174+
raise_mutual_cancellation! if process_error && response_error
175+
raise_incomplete_cancel!(result) if process_error && !response_error
176+
result
177+
end
178+
179+
def raise_mutual_cancellation!
180+
raise process_error # sets the cause
181+
rescue
182+
raise AuthenticationCanceled.new(
183+
"authentication canceled (see error #cause and #response)",
184+
response: response_error
185+
)
186+
end
187+
188+
def raise_incomplete_cancellation!
189+
raise process_error # sets the cause
190+
rescue
191+
raise AuthenticationIncomplete.new(
192+
response_error, "server ignored canceled authentication"
193+
)
194+
end
195+
149196
end
150197
end
151198
end

test/net/imap/test_imap_authenticate.rb

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -166,20 +166,32 @@ class IMAPAuthenticateTest < Net::IMAP::TestCase
166166
end
167167

168168
test "#authenticate can be canceled with SASL::AuthenticationCanceled" do
169+
authenticator = Class.new do
170+
def initialize(*a, **kw, &b) @a, @kw, @b = a, kw, b end
171+
def process(challenge)
172+
raise(Net::IMAP::SASL::AuthenticationCanceled,
173+
"a: %p, kw: %p, b: %p, c: %p" % [@a, @kw, @b, challenge])
174+
end
175+
end
176+
registry = Net::IMAP::SASL::Authenticators.new(use_defaults: false)
177+
registry.add_authenticator :plain, authenticator
178+
169179
with_fake_server(
170180
preauth: false, cleartext_auth: true,
171181
sasl_ir: false, sasl_mechanisms: %i[PLAIN]
172182
) do |server, imap|
173-
registry = Net::IMAP::SASL::Authenticators.new(use_defaults: false)
174-
registry.add_authenticator :plain, ->(*a, **kw, &b) {
175-
->(challenge) {
176-
raise(Net::IMAP::SASL::AuthenticationCanceled,
177-
"a: %p, kw: %p, b: %p" % [a, kw, b])
178-
}
179-
}
180-
assert_raise_with_message(Net::IMAP::BadResponseError, "canceled") do
181-
imap.authenticate(:plain, hello: :world, registry: registry)
183+
error = nil
184+
assert_raise_with_message(Net::IMAP::SASL::AuthenticationCanceled,
185+
/authentication canceled/i) do
186+
imap.authenticate(:plain, foo: :bar, registry: registry)
187+
rescue => error
188+
raise # for assert_raise
182189
end
190+
assert_kind_of Net::IMAP::SASL::AuthenticationCanceled, error.cause
191+
assert_equal("a: %p, kw: %p, b: %p, c: %p" % [[], {foo: :bar}, nil, ""],
192+
error.cause.to_s)
193+
assert_kind_of Net::IMAP::BadResponseError, error.response
194+
assert_equal "canceled", error.response.to_s
183195
refute imap.disconnected?
184196
end
185197
end

0 commit comments

Comments
 (0)