diff --git a/lib/net/imap.rb b/lib/net/imap.rb index d6d62cec..2df3d0b5 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -1002,6 +1002,13 @@ 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[rdoc-ref:Net::IMAP::SASL::PlainAuthenticator]. # diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb index 9fc3666c..7206ce3d 100644 --- a/lib/net/imap/sasl.rb +++ b/lib/net/imap/sasl.rb @@ -27,6 +27,13 @@ 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. # @@ -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..2620076f --- /dev/null +++ b/lib/net/imap/sasl/oauthbearer_authenticator.rb @@ -0,0 +1,168 @@ +# 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. + # + # === Configuration parameters + # + # 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. + # + # See also: PlainAuthenticator#authzid, DigestMD5Authenticator#authzid. + 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 + return "\1" if done? + 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) [RFC5246] 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, **) -> auth_ctx + # new(oauth2_token:, **) -> auth_ctx + # + # Creates an Authenticator for the "+OAUTHBEARER+" SASL mechanism. + # + # Called by Net::IMAP#authenticate and similar methods on other clients. + # + # === Parameters + # + # 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 # ----------------------