diff --git a/lib/net/imap.rb b/lib/net/imap.rb
index 50307b3d..2df3d0b5 100644
--- a/lib/net/imap.rb
+++ b/lib/net/imap.rb
@@ -1002,13 +1002,20 @@ def starttls(options = {}, verify = true)
# Each mechanism has different properties and requirements. Please consult
# the documentation for the specific mechanisms you are using:
#
+ # +OAUTHBEARER+::
+ # See OAuthBearerAuthenticator[rdoc-ref:Net::IMAP::SASL::OAuthBearerAuthenticator].
+ #
+ # Login using an OAuth2 Bearer token. This is the standard mechanism
+ # for using OAuth2 with \SASL, but it is not yet deployed as widely as
+ # +XOAUTH2+.
+ #
# +PLAIN+::
- # See PlainAuthenticator[Net::IMAP::SASL::PlainAuthenticator].
+ # See PlainAuthenticator[rdoc-ref:Net::IMAP::SASL::PlainAuthenticator].
#
# Login using clear-text username and password.
#
# +XOAUTH2+::
- # See XOAuth2Authenticator[Net::IMAP::SASL::XOAuth2Authenticator].
+ # See XOAuth2Authenticator[rdoc-ref:Net::IMAP::SASL::XOAuth2Authenticator].
#
# Login using a username and an OAuth2 access token. Non-standard and
# obsoleted by +OAUTHBEARER+, but widely supported.
diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb
index 921df2ea..7206ce3d 100644
--- a/lib/net/imap/sasl.rb
+++ b/lib/net/imap/sasl.rb
@@ -27,13 +27,20 @@ class IMAP
# Each mechanism has different properties and requirements. Please consult
# the documentation for the specific mechanisms you are using:
#
+ # +OAUTHBEARER+::
+ # See OAuthBearerAuthenticator.
+ #
+ # Login using an OAuth2 Bearer token. This is the standard mechanism
+ # for using OAuth2 with \SASL, but it is not yet deployed as widely as
+ # +XOAUTH2+.
+ #
# +PLAIN+::
- # See PlainAuthenticator[Net::IMAP::SASL::PlainAuthenticator].
+ # See PlainAuthenticator.
#
# Login using clear-text username and password.
#
# +XOAUTH2+::
- # See XOAuth2Authenticator[Net::IMAP::SASL::XOAuth2Authenticator].
+ # See XOAuth2Authenticator.
#
# Login using a username and an OAuth2 access token. Non-standard and
# obsoleted by +OAUTHBEARER+, but widely supported.
@@ -69,7 +76,8 @@ module SASL
sasl_dir = File.expand_path("sasl", __dir__)
autoload :Authenticators, "#{sasl_dir}/authenticators"
-
+ autoload :GS2Header, "#{sasl_dir}/gs2_header"
+ 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 ef193df4..b97861f1 100644
--- a/lib/net/imap/sasl/authenticators.rb
+++ b/lib/net/imap/sasl/authenticators.rb
@@ -33,6 +33,7 @@ class Authenticators
def initialize(use_defaults: false)
@authenticators = {}
if use_defaults
+ add_authenticator "OAuthBearer"
add_authenticator "Plain"
add_authenticator "XOAuth2"
add_authenticator "Login" # deprecated
diff --git a/lib/net/imap/sasl/gs2_header.rb b/lib/net/imap/sasl/gs2_header.rb
new file mode 100644
index 00000000..96c530b9
--- /dev/null
+++ b/lib/net/imap/sasl/gs2_header.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module Net
+ class IMAP < Protocol
+ module SASL
+
+ # Originally defined for the GS2 mechanism family in
+ # RFC5801[https://tools.ietf.org/html/rfc5801],
+ # several different mechanisms start with a GS2 header:
+ # * +GS2-*+ --- RFC5801[https://tools.ietf.org/html/rfc5801]
+ # * +SCRAM-*+ --- RFC5802[https://tools.ietf.org/html/rfc5802]
+ # * +SAML20+ --- RFC6595[https://tools.ietf.org/html/rfc6595]
+ # * +OPENID20+ --- RFC6616[https://tools.ietf.org/html/rfc6616]
+ # * +OAUTH10A+ --- RFC7628[https://tools.ietf.org/html/rfc7628]
+ # * +OAUTHBEARER+ --- RFC7628[https://tools.ietf.org/html/rfc7628]
+ # (OAuthBearerAuthenticator)
+ #
+ # Classes that include this module must implement +#authzid+.
+ module GS2Header
+ NO_NULL_CHARS = /\A[^\x00]+\z/u.freeze # :nodoc:
+
+ ##
+ # Matches {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
+ # +saslname+. The output from gs2_saslname_encode matches this Regexp.
+ RFC5801_SASLNAME = /\A(?:[^,=\x00]|=2C|=3D)+\z/u.freeze
+
+ # The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
+ # +gs2-header+, which prefixes the #initial_client_response.
+ #
+ # >>>
+ # Note: the actual GS2 header includes an optional flag to
+ # indicate that the GSS mechanism is not "standard", but since all of
+ # the SASL mechanisms using GS2 are "standard", we don't include that
+ # flag. A class for a nonstandard GSSAPI mechanism should prefix with
+ # "+F,+".
+ def gs2_header
+ "#{gs2_cb_flag},#{gs2_authzid},"
+ end
+
+ # The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
+ # +gs2-cb-flag+:
+ #
+ # "+n+":: The client doesn't support channel binding.
+ # "+y+":: The client does support channel binding
+ # but thinks the server does not.
+ # "+p+":: The client requires channel binding.
+ # The selected channel binding follows "+p=+".
+ #
+ # The default always returns "+n+". A mechanism that supports channel
+ # binding must override this method.
+ #
+ def gs2_cb_flag; "n" end
+
+ # The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
+ # +gs2-authzid+ header, when +#authzid+ is not empty.
+ #
+ # If +#authzid+ is empty or +nil+, an empty string is returned.
+ def gs2_authzid
+ return "" if authzid.nil? || authzid == ""
+ "a=#{gs2_saslname_encode(authzid)}"
+ end
+
+ module_function
+
+ # Encodes +str+ to match RFC5801_SASLNAME.
+ def gs2_saslname_encode(str)
+ str = str.encode("UTF-8")
+ # Regexp#match raises "invalid byte sequence" for invalid UTF-8
+ NO_NULL_CHARS.match str or
+ raise ArgumentError, "invalid saslname: %p" % [str]
+ str
+ .gsub(?=, "=3D")
+ .gsub(?,, "=2C")
+ end
+
+ end
+ end
+ end
+end
diff --git a/lib/net/imap/sasl/oauthbearer_authenticator.rb b/lib/net/imap/sasl/oauthbearer_authenticator.rb
new file mode 100644
index 00000000..d99fb227
--- /dev/null
+++ b/lib/net/imap/sasl/oauthbearer_authenticator.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+require_relative "gs2_header"
+
+module Net
+ class IMAP < Protocol
+ module SASL
+
+ # Abstract base class for the SASL mechanisms defined in
+ # RFC7628[https://tools.ietf.org/html/rfc7628]:
+ # * OAUTHBEARER[rdoc-ref:OAuthBearerAuthenticator]
+ # (OAuthBearerAuthenticator)
+ # * OAUTH10A
+ class OAuthAuthenticator
+ include GS2Header
+
+ # Creates an RFC7628[https://tools.ietf.org/html/rfc7628] OAuth
+ # authenticator.
+ #
+ # === Options
+ #
+ # See child classes for required configuration parameter(s). The
+ # following parameters are all optional, but protocols or servers may
+ # add requirements for #authzid, #host, #port, or any other parameter.
+ #
+ # * #authzid ― Identity to act as or on behalf of.
+ # * #host — Hostname to which the client connected.
+ # * #port — Service port to which the client connected.
+ # * #mthd — HTTP method
+ # * #path — HTTP path data
+ # * #post — HTTP post data
+ # * #qs — HTTP query string
+ #
+ def initialize(authzid: nil, host: nil, port: nil,
+ mthd: nil, path: nil, post: nil, qs: nil, **)
+ @authzid = authzid
+ @host = host
+ @port = port
+ @mthd = mthd
+ @path = path
+ @post = post
+ @qs = qs
+ @done = false
+ end
+
+ # Authorization identity: an identity to act as or on behalf of.
+ #
+ # If no explicit authorization identity is provided, it is usually
+ # derived from the authentication identity. For the OAuth-based
+ # mechanisms, the authentication identity is the identity established by
+ # the OAuth credential.
+ attr_reader :authzid
+
+ # Hostname to which the client connected.
+ attr_reader :host
+
+ # Service port to which the client connected.
+ attr_reader :port
+
+ # HTTP method. (optional)
+ attr_reader :mthd
+
+ # HTTP path data. (optional)
+ attr_reader :path
+
+ # HTTP post data. (optional)
+ attr_reader :post
+
+ # The query string. (optional)
+ attr_reader :qs
+
+ # Stores the most recent server "challenge". When authentication fails,
+ # this may hold information about the failure reason, as JSON.
+ attr_reader :last_server_response
+
+ # Returns initial_client_response the first time, then "^A".
+ def process(data)
+ @last_server_response = data
+ done? ? "\1" : initial_client_response
+ ensure
+ @done = true
+ end
+
+ # Returns true when the initial client response was sent.
+ #
+ # The authentication should not succeed unless this returns true, but it
+ # does *not* indicate success.
+ def done?; @done end
+
+ # The {RFC7628 §3.1}[https://www.rfc-editor.org/rfc/rfc7628#section-3.1]
+ # formatted response.
+ def initial_client_response
+ kv_pairs = {
+ host: host, port: port, mthd: mthd, path: path, post: post, qs: qs,
+ auth: authorization, # authorization is implemented by subclasses
+ }.compact
+ [gs2_header, *kv_pairs.map {|kv| kv.join("=") }, "\1"].join("\1")
+ end
+
+ # Value of the HTTP Authorization header
+ #
+ # Implemented by subclasses.
+ def authorization; raise "must be implemented by subclass" end
+
+ end
+
+ # Authenticator for the "+OAUTHBEARER+" SASL mechanism, specified in
+ # RFC7628[https://tools.ietf.org/html/rfc7628]. Authenticates using OAuth
+ # 2.0 bearer tokens, as described in
+ # RFC6750[https://tools.ietf.org/html/rfc6750]. Use via
+ # Net::IMAP#authenticate.
+ #
+ # RFC6750[https://tools.ietf.org/html/rfc6750] requires Transport Layer
+ # Security (TLS) to secure the protocol interaction between the client and
+ # the resource server. TLS _MUST_ be used for +OAUTHBEARER+ to protect
+ # the bearer token.
+ class OAuthBearerAuthenticator < OAuthAuthenticator
+
+ # :call-seq:
+ # new(oauth2_token, **options) -> authenticator
+ # new(oauth2_token:, **options) -> authenticator
+ #
+ # Creates an Authenticator for the "+OAUTHBEARER+" SASL mechanism.
+ #
+ # Called by Net::IMAP#authenticate and similar methods on other clients.
+ #
+ # === Options
+ #
+ # Only +oauth2_token+ is required by the mechanism, however protocols
+ # and servers may add requirements for #authzid, #host, #port, or any
+ # other parameter.
+ #
+ # * #oauth2_token — An OAuth2 bearer token or access token. *Required.*
+ # May be provided as either regular or keyword argument.
+ # * #authzid ― Identity to act as or on behalf of.
+ # * #host — Hostname to which the client connected.
+ # * #port — Service port to which the client connected.
+ # * See OAuthAuthenticator documentation for less common parameters.
+ #
+ def initialize(oauth2_token_arg = nil, oauth2_token: nil, **args, &blk)
+ super(**args, &blk) # handles authzid, host, port, etc
+ oauth2_token && oauth2_token_arg and
+ raise ArgumentError, "conflicting values for oauth2_token"
+ @oauth2_token = oauth2_token || oauth2_token_arg or
+ raise ArgumentError, "missing oauth2_token"
+ end
+
+ # An OAuth2 bearer token, generally the access token.
+ attr_reader :oauth2_token
+
+ # :call-seq:
+ # initial_response? -> true
+ #
+ # +OAUTHBEARER+ sends an initial client response.
+ def initial_response?; true end
+
+ # Value of the HTTP Authorization header
+ def authorization; "Bearer #{oauth2_token}" end
+
+ end
+ end
+
+ end
+end
diff --git a/test/net/imap/test_imap_authenticators.rb b/test/net/imap/test_imap_authenticators.rb
index 605eaace..a37fa06d 100644
--- a/test/net/imap/test_imap_authenticators.rb
+++ b/test/net/imap/test_imap_authenticators.rb
@@ -56,6 +56,28 @@ def test_plain_no_null_chars
assert_raise(ArgumentError) { plain("u", "p", authzid: "bad\0authz") }
end
+ # ----------------------
+ # OAUTHBEARER
+ # ----------------------
+
+ def test_oauthbearer_authenticator_matches_mechanism
+ assert_kind_of(Net::IMAP::SASL::OAuthBearerAuthenticator,
+ Net::IMAP::SASL.authenticator("OAUTHBEARER", "tok"))
+ end
+
+ def oauthbearer(*args, **kwargs, &block)
+ Net::IMAP::SASL.authenticator("OAUTHBEARER", *args, **kwargs, &block)
+ end
+
+ def test_oauthbearer_response
+ assert_equal(
+ "n,a=user@example.com,\1host=server.example.com\1port=587\1" \
+ "auth=Bearer mF_9.B5f-4.1JqM\1\1",
+ oauthbearer("mF_9.B5f-4.1JqM", authzid: "user@example.com",
+ host: "server.example.com", port: 587).process(nil)
+ )
+ end
+
# ----------------------
# XOAUTH2
# ----------------------