Skip to content

Commit 540ff63

Browse files
committed
♻️ SASL: Add Authenticator base class w/📚docs [🚧WIP:update w/mechanisms]
* Add many many docs * Add properties, events, and callbacks * Add initial_response? * TODO: add `done?`
1 parent dde0bdf commit 540ff63

File tree

1 file changed

+366
-0
lines changed

1 file changed

+366
-0
lines changed

lib/net/imap/sasl/authenticator.rb

Lines changed: 366 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP < Protocol
5+
module SASL
6+
7+
# A base class for authentication exchange sessions by a SASL client. A new
8+
# authenticator session object must be created for every authentication
9+
# attempt. Specific SASL mechanisms are implemented by subclasses.
10+
# Although it isn't necessary to inherit from this base class, it enables
11+
# useful functionality that is common to all authenticators, e.g. property
12+
# callbacks.
13+
#
14+
# Each mechanism requires and allows different properties and callbacks;
15+
# please consult the documentation for the specific mechanisms you are
16+
# using:
17+
#
18+
# * +PLAIN+ --- PlainAuthenticator
19+
# * +XOAUTH2+ --- TODO: XOauth2Authenticator
20+
# * +EXTERNAL+ --- TODO
21+
# * +ANONYMOUS+ --- TODO
22+
# * +OAUTHBEARER+ --- TODO
23+
# * +SCRAM-SHA-*+ --- TODO
24+
#
25+
# [Deprecated:]
26+
# DIGEST-MD5[rdoc-ref:DigestMD5Authenticator],
27+
# LOGIN[rdoc-ref:LoginAuthenticator], and
28+
# CRAM-MD5[rdoc-ref:CramMD5Authenticator]
29+
#
30+
# \Authenticators should be created and used internally by a protocol
31+
# client's authentication command, e.g. Net::IMAP#authenticate for \IMAP.
32+
# Instead of creating the authenticator directly, the protocol client
33+
# should call Authenticators#authenticator, which delegates to the
34+
# authenticator that has been registered for the requested mechanism.
35+
#
36+
# == Properties
37+
#
38+
# When inheriting classes use rdoc-ref:Authenticator.property and
39+
# #propinit, every property will be configurable by calling
40+
# new[rdoc-ref:.new] with the matching keyword argument. Common
41+
# properties might also allowed as positional arguments. See
42+
# Authenticator@Typical+properties and the documentation on each
43+
# authenticator subclass.
44+
#
45+
# Assigning a Proc or Method will set a callback to lazy load the property
46+
# value the first time it is used. A block sent to new[rdoc-ref:.new]
47+
# will be the default callback for all unassigned properties. See
48+
# Authenticator@Callbacks.
49+
#
50+
# As a special type of "property", some authenticators may support event
51+
# callbacks. These might be assigned by a keyword parameter with the same
52+
# name, or they might only be sent to the default callback. Unlike
53+
# properties, the callback might be called more than once. Authenticator
54+
# implementations should also document all events that are sent.
55+
#
56+
# === Typical properties
57+
#
58+
# The following is not an exhaustive list of properties, and each \SASL
59+
# mechanism only uses a few of these. More detailed descriptions may be
60+
# found in the specific authenticators' documentation. <em>Some of the
61+
# mechanisms that use these properties are not implemented yet.</em>
62+
#
63+
# ==== Auth identity properties
64+
# * +authcid+ --- authentication identity, owner of the credentials;
65+
# sometimes aliased as +username+
66+
# * +authzid+ --- authorization identity, to act as or on behalf of;
67+
# often identical to +authcid+ or inferred from credentials
68+
# * +realm+ --- namespace for identities, e.g. a domain name
69+
# * +anonymous_message+ --- optional token for the +ANONYMOUS+ mechanism
70+
#
71+
# ==== Credentials properties
72+
#
73+
# * +password+ --- password or passphrase, used by many mechanisms
74+
# * +oauth2_token+ --- OAuth2.0 access token, used by +XOAUTH2+ and
75+
# +OAUTHBEARER+
76+
# * +scram_sha1_salted_passwords+, +scram_sha256_salted_password+ ---
77+
# Salted password(s) (with salt and iteration count) for the +SCRAM-*+
78+
# mechanism family. <tt>[salt, iterations, pbkdf2_hmac]</tt> tuple.
79+
# <em>(not implemented yet...)</em>
80+
# * +passcode+ --- passcode for SecurID 2FA <em>(not implemented)</em>
81+
# * +pin+ --- Personal Identification number, e.g. for SecurID 2FA
82+
# <em>(not implemented)</em>
83+
#
84+
# ==== Requested service properties
85+
#
86+
# Some mechanisms need to know more about the service that the
87+
# authentication is for.
88+
#
89+
# * +service+ --- {registered GSSAPI service
90+
# name}[https://www.iana.org/assignments/gssapi-service-names/gssapi-service-names.xhtml],
91+
# e.g. "imap", "smtp", "ldap", "xmpp"
92+
# * +host+ --- FQDN used to access the requested service
93+
# * +port+ --- port number used to access the requested service
94+
#
95+
#--
96+
# TODO: Authenticators#authenticator should:
97+
# * use #sasl_service, #host, and #port (maybe make these configurable,
98+
# e.g. #hostname vs #host) to automatically fill in the correct values,
99+
# per connection.
100+
# * inspect the authenticator's parameters and automatically send these
101+
# parameters IFF they are explicit keyword arguments.
102+
#++
103+
#
104+
# === Callbacks
105+
#
106+
# Callback procs might be used for interactivity, e.g. to present the user
107+
# with a choice from a server-provided list of realms, or to ask the user
108+
# for a password interactively only after securely connecting to the
109+
# server and selecting a password-based mechanism. It might also be used
110+
# to fetch credentials from a KMS only when necessary, or to audit every
111+
# use of a secret.
112+
#
113+
# Callbacks must return +nil+ for unhandled properties or events.
114+
#
115+
# ==== Example: Interactive password from password manager
116+
# service = { service: "imap", host: "imap.example.com", port: 993 }
117+
# auth = authenticator("DIGEST-MD5", "user", **service) {|ctx, attr|
118+
# case attr
119+
# when :password then password_manager.prompt(**service)
120+
# else
121+
# # etc...
122+
# end
123+
# }
124+
#
125+
# ==== Example: Log every time sensitive fields are accessed
126+
# auth = authenticator(mechanism, **nonsecret_attrs) do |attr, ctx|
127+
# case attr
128+
# when :password, :pin, :oauth2_token, :passphrase,
129+
# :scram_salted_password
130+
# log_sensitive ctx.authcid, attr
131+
# secrets_for(ctx.authcid).fetch(attr)
132+
# end
133+
# nil
134+
# end
135+
#
136+
# ==== Example: some mechanisms need event handlers to finish the exchange
137+
# auth = authenticator("OPENID20", **user_auth_attrs) do |attr, ctx|
138+
# case attr
139+
# when :openid20_authenticate_in_browser
140+
# # an OPENID20 mechanism (not implemented yet) would need to
141+
# # update ctx with values sent by the server challenge:
142+
# browser.open ctx.openid20_redirect_url
143+
# wait_for browser_auth_completion
144+
# end
145+
# end
146+
#
147+
# == Initial Response
148+
#
149+
# Some protocols and some mechanisms support sending an "initial response"
150+
# from the client, before any server challenge. When both the protocol
151+
# client and the authenticator agree to an initial response, the
152+
# authenticator should be called the first time with +nil+ as the "server
153+
# challenge". See rdoc-ref:Authenticator.initial_response?,
154+
# #initial_response?, and #process for more details.
155+
#
156+
class Authenticator
157+
158+
class << self
159+
# Returns the SASL mechanism name, based on the class name. If the
160+
# class name can't produce the correct mechanism name, override
161+
# to return the correct name.
162+
def mechanism_name
163+
name
164+
.split("::").last
165+
.delete_suffix("Authenticator")
166+
.gsub(/([a-z\d])(?=[A-Z])/) {
167+
($1 || $2) << "-"
168+
}
169+
.upcase
170+
end
171+
172+
private
173+
174+
# Adds this authenticator to an Authenticators registry.
175+
def register(registrar) # :doc:
176+
registrar.add_authenticator mechanism_name, self
177+
end
178+
179+
# Defines an authenticator "property"
180+
def property(name) # :doc:
181+
class_eval <<~RUBY, __FILE__, __LINE__ + 1
182+
def #{name} # def password
183+
propget(:#{name}) # propget(:password)
184+
end # end
185+
186+
def #{name}=(value) # def password=(value)
187+
propset(:#{name}, value) # propset(:password, value)
188+
end # end
189+
RUBY
190+
end
191+
end
192+
193+
# A Hash containing all _resolved_ properties for an Authenticator.
194+
# Lazy loaded properties will have an entry in #callbacks. Use the
195+
# named property method to load that property using #callbacks.
196+
attr_reader :properties
197+
198+
# A Hash of callback procs to lazy-load properties or handle events.
199+
# The hash's default value is #callback.
200+
#
201+
# Callbacks are sent the property (or event) name and the authenticator
202+
# object. The authenticator can use itself to pass more information to
203+
# event handlers (e.g. a list of server-sent realms or an authentication
204+
# URL).
205+
attr_reader :callbacks
206+
207+
# The generic callback, called for otherwise unconfigured properties and
208+
# events. Used as the default value for #callbacks.
209+
attr_reader :callback
210+
211+
# :call-seq:
212+
# new -> authenticator
213+
# new(username, password) -> authenticator
214+
# new(authcid, secret, authzid) -> authenticator
215+
# new(*credentials) -> authenticator
216+
# new(**properties, **callbacks) -> authenticator
217+
# new {|name, authenticator| prop_value } -> authenticator
218+
# new(*creds, **props) {|name, authenticator| v } -> authenticator
219+
#
220+
# Creates a new SASL client authenticator. The call signatures listed
221+
# here are the _recommended_ styles, but each mechanism requires and
222+
# allows different arguments. <em>Each authenticator class must
223+
# document its specific properties and callbacks.</em>
224+
#
225+
# Protocol clients should not call this method directly, but should use
226+
# Authenticators#authenticator, which delegates to the authenticator
227+
# that has been registered for the requested mechanism.
228+
#
229+
# See Authenticator@Properties.
230+
#
231+
# ==== Inheriting
232+
#
233+
# <em>Each authenticator class must document its specific properties and
234+
# callbacks.</em>
235+
#
236+
# Inheriting classes must call +super+ before initializing or using
237+
# properties. \Authenticators should have a keyword parameter for every
238+
# accepted property or callback and should ignore any unused keyword
239+
# parameters. \Authenticators may also have one or more positional
240+
# parameters for the most common properties---especially credentials
241+
# like username and password. \Authenticators should raise an
242+
# ArgumentError for unhandled positional arguments or when a property is
243+
# set through multiple arguments.
244+
#
245+
def initialize(*, **, &callback)
246+
@callback = callback
247+
@callbacks = Hash.new { @callback }
248+
@properties = {}
249+
end
250+
251+
# :call-seq:
252+
# obj.process(nil) -> initial_response_string
253+
# obj.process(server_challenge_string) -> client_response_string
254+
#
255+
# Generates a \SASL client reply to a server challenge, as part of a
256+
# \SASL authentication exchange. Protocol clients will call +#process+
257+
# with every server challenge and send the result back to the server,
258+
# until the \SASL exchange is done.
259+
#
260+
# If the protocol client supports an initial client response, it will
261+
# check if the authenticator responds to +initial_response?+ with
262+
# +true+. When the mechanism and the protocol both support an initial
263+
# client response, the client should send +nil+ as the first "server
264+
# challenge".
265+
#
266+
# The protocol client is responsible for protocol-specific decoding of the
267+
# +server_challenge_string+ sent from the server and encoding of the
268+
# +client_response_string+ sent to the server. E.g. \IMAP uses Base64.
269+
#
270+
# If a failed exchange can be detected from the server challenge,
271+
# +#process+ should raise an exception. The Authenticator alone cannot
272+
# determine if an exchange was successful. The protocol client must be
273+
# responsible for handling protocol-level errors (e.g. a +NO+ or +BAD+
274+
# response in IMAP). The \SASL exchange is unsuccessful if _either_ the
275+
# Authenticator or the protocol client report an error.
276+
#
277+
# See PlainAuthenticator or DigestMD5Authenticator for example
278+
# authenticator implementations.
279+
def process(server_challenge_string)
280+
raise NotImplementedError, "#{__method__} is defined by subclasses"
281+
end
282+
283+
# :call-seq:
284+
# initial_response? -> true or false
285+
#
286+
# Returns +true+ when the authenticator supports an "initial response"
287+
# from the client. If this is +true+ and the protocol client also
288+
# supports an initial response, then #process should be called with
289+
# +nil+ the first time.
290+
#
291+
# Automatically returns +true+ when the object responds to
292+
# +initial_client_response+.
293+
#
294+
# Note::
295+
# Implementing #initial_response? or #initial_client_response or
296+
# inheriting from Authenticator are all optional. Clients should
297+
# check that the authenticator responds to +initial_response?+ before
298+
# calling it.
299+
def initial_response?; respond_to?(:initial_client_response) end
300+
301+
private
302+
303+
# Use this instead of #propset when a property may be set by multiple
304+
# sources, e.g. a positional parameter or a keyword parameter or a
305+
# keyword alias, etc.
306+
#
307+
# If +values+ has multiple non-nil members then an ArgumentError will be
308+
# raised, so the order is not semantically significant. With no non-nil
309+
# members, the property will be left unset.
310+
#
311+
# Send +required: true+ to raise an ArgumentError when all of the
312+
# +values+ are +nil+.
313+
def propinit(name, *values, required: false) # :doc:
314+
values.compact!
315+
if values.length > 1
316+
raise ArgumentError, "multiple conflicting arguments for #{name}"
317+
elsif !values.empty?
318+
send :"#{name}=", values.first
319+
elsif required && !callback
320+
raise ArgumentError, "%s needs %s" % [self.class, name]
321+
end
322+
end
323+
324+
# Used internally to assign properties. The autogenerated property
325+
# writers use #propset.
326+
#
327+
# Subclasses can override this to add validation, coercion, etc
328+
def propset(name, val) # :doc:
329+
case val
330+
when nil then properties.delete name; callbacks.delete name
331+
when Proc, Method then properties.delete name; callbacks[name] = val
332+
else callbacks.delete name; properties[name] = val
333+
end
334+
val
335+
end
336+
337+
# Returns whether the property has been set, either to a value or a
338+
# callback.
339+
def propset?(name) # :doc:
340+
properties.key?(name) || callbacks.key?(name)
341+
end
342+
343+
# Used internally to fetch or lazy load properties, using #event and
344+
# #propset. The autogenerated property readers use #propget.
345+
def propget(name, &default) # :doc:
346+
properties.fetch(name) {
347+
val = event(name)
348+
propset(name, val.nil? ? default&.call : val)
349+
}
350+
end
351+
352+
# Use #event to trigger a specific callback, without updating the
353+
# authenticator's state. Accepts no other arguments, but "event
354+
# parameters" can be passed via authenticator attributes or properties.
355+
#
356+
# #propget uses #event internally for lazy-loaded properties, and then
357+
# assigns the return value to the property with that +name+.
358+
def event(name) # :doc:
359+
callbacks[name]&.call(name, self)
360+
end
361+
362+
end
363+
end
364+
365+
end
366+
end

0 commit comments

Comments
 (0)