From f22fe3480cd26ab1622484cc74c921b19adfcc3b Mon Sep 17 00:00:00 2001 From: nick evans Date: Tue, 15 Nov 2022 20:52:59 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20SASL=20OAUTHBEARER:=20Add=20mechani?= =?UTF-8?q?sm=20[=F0=9F=9A=A7=20more=20tests]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also, GS2Header was extracted from OAuthBearerAuthenticator. It's not much right now, but it will be re-used in the implementation of other mechanisms, e.g. `SCRAM-SHA-*`. --- lib/net/imap/sasl.rb | 8 + lib/net/imap/sasl/gs2_header.rb | 79 +++++++ .../imap/sasl/oauthbearer_authenticator.rb | 192 ++++++++++++++++++ test/net/imap/test_imap_authenticators.rb | 22 ++ 4 files changed, 301 insertions(+) create mode 100644 lib/net/imap/sasl/gs2_header.rb create mode 100644 lib/net/imap/sasl/oauthbearer_authenticator.rb diff --git a/lib/net/imap/sasl.rb b/lib/net/imap/sasl.rb index e94a9042..62096f86 100644 --- a/lib/net/imap/sasl.rb +++ b/lib/net/imap/sasl.rb @@ -33,6 +33,11 @@ class IMAP # +PLAIN+:: See PlainAuthenticator. # Login using clear-text username and password. # + # +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+. + # # +XOAUTH2+:: See XOAuth2Authenticator. # Login using a username and OAuth2 access token. # Non-standard and obsoleted by +OAUTHBEARER+, but widely @@ -71,8 +76,10 @@ module SASL sasl_dir = File.expand_path("sasl", __dir__) autoload :Authenticator, "#{sasl_dir}/authenticator" autoload :Authenticators, "#{sasl_dir}/authenticators" + 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" @@ -85,6 +92,7 @@ def self.authenticators @authenticators ||= SASL::Authenticators.new.tap do |registry| registry.add_authenticator "Anonymous" registry.add_authenticator "External" + registry.add_authenticator "OAuthBearer" registry.add_authenticator "Plain" registry.add_authenticator "XOAuth2" registry.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..74ff30fe --- /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 + + # Several mechanisms start with a GS2 header: + # * +GS2-*+ + # * +SCRAM-*+ --- ScramAuthenticator + # * +OPENID20+ + # * +SAML20+ + # * +OAUTH10A+ + # * +OAUTHBEARER+ --- OAuthBearerAuthenticator + # + # Classes that include this 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. + # + #-- + # TODO: validate NO_NULL_CHARS and valid UTF-8 in the attr_writer. + 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..18f27490 --- /dev/null +++ b/lib/net/imap/sasl/oauthbearer_authenticator.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require_relative "authenticator" +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] + # * OAUTH10A + class OAuthAuthenticator < Authenticator + include GS2Header + + # Creates an OAuthBearerAuthenticator or OAuth10aAuthenticator. + # + # * +_subclass_var_+ — the subclass's required 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 + # + # All properties here are optional. See the child classes for their + # required parameter(s). + # + def initialize(arg1_authzid = nil, _ = nil, arg3_authzid = nil, + authzid: nil, host: nil, port: nil, + mthd: nil, path: nil, post: nil, qs: nil, **) + super + propinit(:authzid, authzid, arg1_authzid, arg3_authzid) + self.host = host + self.port = port + self.mthd = mthd + self.path = path + self.post = post + self.qs = qs + @done = false + end + + ## + # Authorization identity: an identity to act as or on behalf of. + # + # For the OAuth-based mechanisms, authcid is implicitly set by the + # #auth_payload. It may be useful to make it explicit, which allows the + # server to verify the credentials match the identity. The gs2_header + # MAY include the username associated with the resource being accessed, + # the "authzid". + # + # It is worth noting that application protocols are allowed to require + # an authzid, as are specific server implementations. + # + # See also: PlainAuthenticator#authzid, DigestMD5Authenticator#authzid. + property :authzid + + ## + # Hostname to which the client connected. + property :host + + ## + # Service port to which the client connected. + property :port + + ## + # HTTP method. (optional) + property :mthd + + ## + # HTTP path data. (optional) + property :path + + ## + # HTTP post data. (optional) + property :post + + ## + # The query string. (optional) + property :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 + [gs2_header, *kv_pairs.map {|kv| kv.join("=") }, "\1"].join("\1") + end + + # The key value pairs which follow gs2_header, as a Hash. + def kv_pairs + { + host: host, port: port, mthd: mthd, path: path, post: post, qs: qs, + auth: auth_payload, # auth_payload is implemented by subclasses + }.compact + end + + # What would be sent in the HTTP Authorization header. + # + # Implemented by subclasses. + def auth_payload; raise NotImplementedError, "implement in subclass" end + + end + + # Authenticator for the "+OAUTHBEARER+" SASL mechanism, specified in + # RFC7628[https://tools.ietf.org/html/rfc7628]. Use via + # Net::IMAP#authenticate. + # + # TODO... + # + # OAuth 2.0 bearer tokens, as described in [RFC6750]. + # RFC6750 uses 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(authzid, oauth2_token, **) -> auth_ctx + # new(oauth2_token:, authzid: nil, **) -> auth_ctx + # new(**) {|propname, auth_ctx| propval } -> auth_ctx + # + # Creates an Authenticator for the "+OAUTHBEARER+" SASL mechanism. + # + # Called by Net::IMAP#authenticate and similar methods on other clients. + # + # === Properties + # + # * #oauth2_token — An OAuth2 bearer token or access token. *Required* + # * #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 other, rarely used properties on OAuthAuthenticator. + # + # Although only #oauth2_token is required, specific server + # implementations may additionally require #authzid, #host, and #port. + # + # See the documentation on each property method for more details. + # + # All three properties may be sent as either positional or keyword + # arguments. See Net::IMAP::SASL::Authenticator@Properties for a + # detailed description of property assignment, lazy loading, and + # callbacks. + # + def initialize(id1=nil, arg2_token=nil, id3=nil, oauth2_token: nil, **) + super # handles authzid, host, port, callback, etc + propinit(:oauth2_token, oauth2_token, arg2_token, required: true) + self.host = host + end + + ## + # An OAuth2 bearer token, which is generally the same as the standard + # access_token. + property :oauth2_token + + # :call-seq: + # initial_response? -> true + # + # +OAUTHBEARER+ sends an initial client response. + def initial_response?; true end + + # What would be sent in the HTTP Authorization header. + def auth_payload; "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 c3d2a056..34d7918a 100644 --- a/test/net/imap/test_imap_authenticators.rb +++ b/test/net/imap/test_imap_authenticators.rb @@ -40,6 +40,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", nil, "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("user@example.com", "mF_9.B5f-4.1JqM", + host: "server.example.com", port: 587).process(nil) + ) + end + # ---------------------- # XOAUTH2 # ----------------------