diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 2fd6e153..a07d5858 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -1008,6 +1008,12 @@ def starttls(options = {}, verify = true) # Allows the user to gain access to public services or resources without # authenticating or disclosing an identity. # + # +EXTERNAL+:: + # See ExternalAuthenticator[Net::IMAP::SASL::ExternalAuthenticator]. + # + # Authenticates using already established credentials, such as a TLS + # certificate or IPsec. + # # +OAUTHBEARER+:: # See OAuthBearerAuthenticator[rdoc-ref:Net::IMAP::SASL::OAuthBearerAuthenticator]. # diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb index 8efcee9c..7ea0edc7 100644 --- a/lib/net/imap/sasl.rb +++ b/lib/net/imap/sasl.rb @@ -33,6 +33,12 @@ class IMAP # Allows the user to gain access to public services or resources without # authenticating or disclosing an identity. # + # +EXTERNAL+:: + # See ExternalAuthenticator[Net::IMAP::SASL::ExternalAuthenticator]. + # + # Authenticates using already established credentials, such as a TLS + # certificate or IPsec. + # # +OAUTHBEARER+:: # See OAuthBearerAuthenticator. # @@ -85,6 +91,7 @@ module SASL autoload :GS2Header, "#{sasl_dir}/gs2_header" autoload :AnonymousAuthenticator, "#{sasl_dir}/anonymous_authenticator" + autoload :ExternalAuthenticator, "#{sasl_dir}/external_authenticator" autoload :OAuthBearerAuthenticator, "#{sasl_dir}/oauthbearer_authenticator" autoload :PlainAuthenticator, "#{sasl_dir}/plain_authenticator" autoload :XOAuth2Authenticator, "#{sasl_dir}/xoauth2_authenticator" diff --git a/lib/net/imap/sasl/authenticators.rb b/lib/net/imap/sasl/authenticators.rb index d2cb8cd3..ed55d1c6 100644 --- a/lib/net/imap/sasl/authenticators.rb +++ b/lib/net/imap/sasl/authenticators.rb @@ -34,6 +34,7 @@ def initialize(use_defaults: false) @authenticators = {} if use_defaults add_authenticator "Anonymous" + add_authenticator "External" add_authenticator "OAuthBearer" add_authenticator "Plain" add_authenticator "XOAuth2" diff --git a/lib/net/imap/sasl/external_authenticator.rb b/lib/net/imap/sasl/external_authenticator.rb new file mode 100644 index 00000000..e49af473 --- /dev/null +++ b/lib/net/imap/sasl/external_authenticator.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Net + class IMAP < Protocol + module SASL + + # Authenticator for the "+EXTERNAL+" SASL mechanism, as specified by + # RFC-4422[https://tools.ietf.org/html/rfc4422]. See + # Net::IMAP#authenticate. + # + # The EXTERNAL mechanism requests that the server use client credentials + # established external to SASL, for example by TLS certificate or IPsec. + class ExternalAuthenticator + + # :call-seq: + # new(authzid: nil, **) -> authenticator + # + # Creates an Authenticator for the "+EXTERNAL+" SASL mechanism, as + # specified in RFC-4422[https://tools.ietf.org/html/rfc4422]. To use + # this, see Net::IMAP#authenticate or your client's authentication + # method. + # + # #authzid is an optional identity to act as or on behalf of. + # + # Any other keyword parameters are quietly ignored. + def initialize(authzid: nil) + @authzid = authzid&.to_str&.encode "UTF-8" + if @authzid&.match?(/\u0000/u) # also validates UTF8 encoding + raise ArgumentError, "contains NULL" + end + end + + # Authorization identity: an identity to act as or on behalf of. + # + # If not explicitly provided, the server defaults to using the identity + # that was authenticated by the external credentials. + attr_reader :authzid + + # :call-seq: + # initial_response? -> true + # + # +EXTERNAL+ can send an initial client response. + def initial_response?; true end + + # Returns #authzid, or an empty string if there is no authzid. + def process(_) + authzid || "" + end + + end + end + end +end diff --git a/test/net/imap/test_imap_authenticators.rb b/test/net/imap/test_imap_authenticators.rb index d7966e18..79ce2076 100644 --- a/test/net/imap/test_imap_authenticators.rb +++ b/test/net/imap/test_imap_authenticators.rb @@ -135,6 +135,35 @@ def test_anonymous_length_over_255 assert_raise(ArgumentError) { anonymous("a" * 256).process(nil) } end + # ---------------------- + # EXTERNAL + # ---------------------- + + def external(...) + Net::IMAP::SASL.authenticator("EXTERNAL", ...) + end + + def test_external_matches_mechanism + assert_kind_of(Net::IMAP::SASL::ExternalAuthenticator, external) + end + + def test_external_response + assert_equal("", external.process(nil)) + assert_equal("kwarg", external(authzid: "kwarg").process(nil)) + end + + def test_external_utf8 + assert_equal("", external.process(nil)) + assert_equal("🏴󠁧󠁢󠁥󠁮󠁧󠁿 England", + external(authzid: "🏴󠁧󠁢󠁥󠁮󠁧󠁿 England").process(nil)) + end + + def test_external_invalid + assert_raise(ArgumentError) { external(authzid: "bad\0contains NULL") } + assert_raise(ArgumentError) { external(authzid: "invalid utf8\x80") } + assert_raise(ArgumentError) { external("invalid positional argument") } + end + # ---------------------- # LOGIN (obsolete) # ----------------------