From 5bc8bff54f942eff6f7c0d8dfe9a2d441334f56c Mon Sep 17 00:00:00 2001 From: nick evans Date: Thu, 19 Oct 2023 19:38:51 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=93=96=20Update=20SASL=20mechanism=20?= =?UTF-8?q?parameter=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harmonized the formatting and language of SASL mechanism parameters. Made a few corrections (e.g. where `PLAIN` should have been `XOAUTH2`). Added some RFC reference links. Etc. --- lib/net/imap/sasl/anonymous_authenticator.rb | 5 +- lib/net/imap/sasl/digest_md5_authenticator.rb | 12 +++- lib/net/imap/sasl/external_authenticator.rb | 18 +++-- .../imap/sasl/oauthbearer_authenticator.rb | 69 +++++++++++-------- lib/net/imap/sasl/plain_authenticator.rb | 10 ++- lib/net/imap/sasl/scram_authenticator.rb | 12 +++- lib/net/imap/sasl/xoauth2_authenticator.rb | 21 ++++-- 7 files changed, 96 insertions(+), 51 deletions(-) diff --git a/lib/net/imap/sasl/anonymous_authenticator.rb b/lib/net/imap/sasl/anonymous_authenticator.rb index ed7a46ef..aa4ecae4 100644 --- a/lib/net/imap/sasl/anonymous_authenticator.rb +++ b/lib/net/imap/sasl/anonymous_authenticator.rb @@ -29,8 +29,9 @@ class AnonymousAuthenticator # this, see Net::IMAP#authenticate or your client's authentication # method. # - # #anonymous_message is an optional message which is sent to the server. - # It may be sent as a positional argument or as a keyword argument. + # ==== Parameters + # + # * _optional_ #anonymous_message — a message to send to the server. # # Any other keyword arguments are silently ignored. def initialize(anon_msg = nil, anonymous_message: nil, **) diff --git a/lib/net/imap/sasl/digest_md5_authenticator.rb b/lib/net/imap/sasl/digest_md5_authenticator.rb index dcc6fc59..233bc98a 100644 --- a/lib/net/imap/sasl/digest_md5_authenticator.rb +++ b/lib/net/imap/sasl/digest_md5_authenticator.rb @@ -53,10 +53,16 @@ class Net::IMAP::SASL::DigestMD5Authenticator # # * #username — Identity whose #password is used. # * #password — A password or passphrase associated with this #username. - # * #authzid ― Alternate identity to act as or on behalf of. Optional. - # * +warn_deprecation+ — Set to +false+ to silence the warning. # - # See the documentation for each attribute for more details. + # + # * _optional_ #authzid ― Authorization identity to act as or on behalf of. + # + # When +authzid+ is not set, the server should derive the authorization + # identity from the authentication identity. + # + # * _optional_ +warn_deprecation+ — Set to +false+ to silence the warning. + # + # Any other keyword arguments are silently ignored. def initialize(user = nil, pass = nil, authz = nil, username: nil, password: nil, authzid: nil, warn_deprecation: true, **) diff --git a/lib/net/imap/sasl/external_authenticator.rb b/lib/net/imap/sasl/external_authenticator.rb index f229c63d..2681ef49 100644 --- a/lib/net/imap/sasl/external_authenticator.rb +++ b/lib/net/imap/sasl/external_authenticator.rb @@ -12,10 +12,18 @@ module SASL # established external to SASL, for example by TLS certificate or IPsec. class ExternalAuthenticator - # Authorization identity: an identity to act as or on behalf of. + # Authorization identity: an identity to act as or on behalf of. The + # identity form is application protocol specific. If not provided or + # left blank, the server derives an authorization identity from the + # authentication identity. The server is responsible for verifying the + # client's credentials and verifying that the identity it associates + # with the client's authentication identity is allowed to act as (or on + # behalf of) the authorization identity. + # + # For example, an administrator or superuser might take on another role: + # + # imap.authenticate "PLAIN", "root", passwd, authzid: "user" # - # If not explicitly provided, the server defaults to using the identity - # that was authenticated by the external credentials. attr_reader :authzid # :call-seq: @@ -26,7 +34,9 @@ class ExternalAuthenticator # this, see Net::IMAP#authenticate or your client's authentication # method. # - # #authzid is an optional identity to act as or on behalf of. + # ==== Parameters + # + # * _optional_ #authzid ― Authorization identity to act as or on behalf of. # # Any other keyword parameters are quietly ignored. def initialize(authzid: nil, **) diff --git a/lib/net/imap/sasl/oauthbearer_authenticator.rb b/lib/net/imap/sasl/oauthbearer_authenticator.rb index c23c35f9..28f108fd 100644 --- a/lib/net/imap/sasl/oauthbearer_authenticator.rb +++ b/lib/net/imap/sasl/oauthbearer_authenticator.rb @@ -14,18 +14,24 @@ module SASL class OAuthAuthenticator include GS2Header - # Authorization identity: an identity to act as or on behalf of. + # Authorization identity: an identity to act as or on behalf of. The + # identity form is application protocol specific. If not provided or + # left blank, the server derives an authorization identity from the + # authentication identity. The server is responsible for verifying the + # client's credentials and verifying that the identity it associates + # with the client's authentication identity is allowed to act as (or on + # behalf of) the authorization identity. + # + # For example, an administrator or superuser might take on another role: + # + # imap.authenticate "PLAIN", "root", passwd, authzid: "user" # - # 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. + # Hostname to which the client connected. (optional) attr_reader :host - # Service port to which the client connected. + # Service port to which the client connected. (optional) attr_reader :port # HTTP method. (optional) @@ -47,20 +53,22 @@ class OAuthAuthenticator # Creates an RFC7628[https://tools.ietf.org/html/rfc7628] OAuth # authenticator. # - # === Options + # ==== 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. + # See child classes for required parameter(s). The following parameters + # are all optional, but it is worth noting that application protocols + # are allowed to require #authzid (or other parameters, such as + # #host or #port) as are specific server implementations. # - # * #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 + # * _optional_ #authzid ― Authorization identity to act as or on behalf of. + # * _optional_ #host — Hostname to which the client connected. + # * _optional_ #port — Service port to which the client connected. + # * _optional_ #mthd — HTTP method + # * _optional_ #path — HTTP path data + # * _optional_ #post — HTTP post data + # * _optional_ #qs — HTTP query string # + # Any other keyword parameters are quietly ignored. def initialize(authzid: nil, host: nil, port: nil, mthd: nil, path: nil, post: nil, qs: nil, **) @authzid = authzid @@ -116,7 +124,7 @@ def authorization; raise "must be implemented by subclass" end # the bearer token. class OAuthBearerAuthenticator < OAuthAuthenticator - # An OAuth2 bearer token, generally the access token. + # An OAuth 2.0 bearer token. See {RFC-6750}[https://www.rfc-editor.org/rfc/rfc6750] attr_reader :oauth2_token # :call-seq: @@ -127,19 +135,22 @@ class OAuthBearerAuthenticator < OAuthAuthenticator # # Called by Net::IMAP#authenticate and similar methods on other clients. # - # === Options + # ==== Parameters + # + # * #oauth2_token — An OAuth2 bearer token # - # Only +oauth2_token+ is required by the mechanism, however protocols - # and servers may add requirements for #authzid, #host, #port, or any - # other parameter. + # All other keyword parameters are passed to + # {super}[rdoc-ref:OAuthAuthenticator::new] (see OAuthAuthenticator). + # The most common ones are: # - # * #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. + # * _optional_ #authzid ― Authorization identity to act as or on behalf of. + # * _optional_ #host — Hostname to which the client connected. + # * _optional_ #port — Service port to which the client connected. # + # Although only oauth2_token is required by this mechanism, it is worth + # noting that application protocols are allowed to + # require #authzid (or other parameters, such as #host + # _or_ #port) as are specific server implementations. 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 diff --git a/lib/net/imap/sasl/plain_authenticator.rb b/lib/net/imap/sasl/plain_authenticator.rb index c8539282..f7a0b8c8 100644 --- a/lib/net/imap/sasl/plain_authenticator.rb +++ b/lib/net/imap/sasl/plain_authenticator.rb @@ -47,13 +47,17 @@ class Net::IMAP::SASL::PlainAuthenticator # # Called by Net::IMAP#authenticate and similar methods on other clients. # - # === Parameters + # ==== Parameters # # * #username ― Identity whose +password+ is used. # * #password ― Password or passphrase associated with this username+. - # * #authzid ― Alternate identity to act as or on behalf of. Optional. # - # See attribute documentation for more details. + # * _optional_ #authzid ― Authorization identity to act as or on behalf of. + # + # When +authzid+ is not set, the server should derive the authorization + # identity from the authentication identity. + # + # Any other keyword parameters are quietly ignored. def initialize(user = nil, pass = nil, username: nil, password: nil, authzid: nil, **) [username, user].compact.count == 1 or diff --git a/lib/net/imap/sasl/scram_authenticator.rb b/lib/net/imap/sasl/scram_authenticator.rb index d8d36947..afb1cebc 100644 --- a/lib/net/imap/sasl/scram_authenticator.rb +++ b/lib/net/imap/sasl/scram_authenticator.rb @@ -70,10 +70,10 @@ class ScramAuthenticator # # * #username ― Identity whose #password is used. Aliased as #authcid. # * #password ― Password or passphrase associated with this #username. - # * #authzid ― Alternate identity to act as or on behalf of. Optional. - # * #min_iterations - Overrides the default value (4096). Optional. + # * _optional_ #authzid ― Alternate identity to act as or on behalf of. + # * _optional_ #min_iterations - Overrides the default value (4096). # - # See the documentation on the corresponding attributes for more. + # Any other keyword parameters are quietly ignored. def initialize(username_arg = nil, password_arg = nil, username: nil, password: nil, authcid: nil, authzid: nil, min_iterations: 4096, # see both RFC5802 and RFC7677 @@ -96,6 +96,12 @@ def initialize(username_arg = nil, password_arg = nil, end # Authentication identity: the identity that matches the #password. + # + # RFC-2831[https://tools.ietf.org/html/rfc2831] uses the term +username+. + # "Authentication identity" is the generic term used by + # RFC-4422[https://tools.ietf.org/html/rfc4422]. + # RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs abbreviate + # this to +authcid+. attr_reader :username alias authcid username diff --git a/lib/net/imap/sasl/xoauth2_authenticator.rb b/lib/net/imap/sasl/xoauth2_authenticator.rb index 164afa0d..a11e1067 100644 --- a/lib/net/imap/sasl/xoauth2_authenticator.rb +++ b/lib/net/imap/sasl/xoauth2_authenticator.rb @@ -6,9 +6,10 @@ # Google[https://developers.google.com/gmail/imap/xoauth2-protocol] and # Microsoft[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth]. # -# This mechanism requires an OAuth2 +access_token+ which has been authorized -# with the appropriate OAuth2 scopes to access IMAP. These scopes are not -# standardized---consult each email service provider's documentation. +# This mechanism requires an OAuth2 access token which has been authorized +# with the appropriate OAuth2 scopes to access the user's services. Most of +# these scopes are not standardized---consult each service provider's +# documentation for their scopes. # # Although this mechanism was never standardized and has been obsoleted by # "+OAUTHBEARER+", it is still very widely supported. @@ -19,12 +20,18 @@ class Net::IMAP::SASL::XOAuth2Authenticator # It is unclear from {Google's original XOAUTH2 # documentation}[https://developers.google.com/gmail/imap/xoauth2-protocol], # whether "User" refers to the authentication identity (+authcid+) or the - # authorization identity (+authzid+). It appears to behave as +authzid+. + # authorization identity (+authzid+). The authentication identity is + # established for the client by the OAuth token, so it seems that +username+ + # must be the authorization identity. # # {Microsoft's documentation for shared # mailboxes}[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth#sasl-xoauth2-authentication-for-shared-mailboxes-in-office-365] - # clearly indicate that the Office 365 server interprets it as the + # _clearly_ indicates that the Office 365 server interprets it as the # authorization identity. + # + # Although they _should_ validate that the token has been authorized to access + # the service for +username+, _some_ servers appear to ignore this field, + # relying only the identity and scope authorized by the token. attr_reader :username # An OAuth2 access token which has been authorized with the appropriate OAuth2 @@ -46,7 +53,7 @@ class Net::IMAP::SASL::XOAuth2Authenticator # * #oauth2_token --- An OAuth2.0 access token which is authorized to access # the service for #username. # - # See the documentation for each attribute for more details. + # Any other keyword parameters are quietly ignored. def initialize(user = nil, token = nil, username: nil, oauth2_token: nil, **) @username = username || user or raise ArgumentError, "missing username" @@ -62,7 +69,7 @@ def initialize(user = nil, token = nil, username: nil, oauth2_token: nil, **) # :call-seq: # initial_response? -> true # - # +PLAIN+ can send an initial client response. + # +XOAUTH2+ can send an initial client response. def initial_response?; true end # Returns the XOAUTH2 formatted response, which combines the +username+ From 5f9027e4b666c0f5d3db8017dca04c97ec8328d6 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 2 Oct 2023 22:57:26 -0400 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=94=92=20Clarify=20usage=20of=20usern?= =?UTF-8?q?ame=20vs=20authcid=20vs=20authzid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Different SASL mechanisms use the term "username" differently. In general the pattern seems to be the following: * Some mechanisms avoid using the term `username` at all, and instead use the terms Authentication Identity (`authcid`) and Authorization Identity (`authzid`). * Older or non-standard mechanisms may not distinguish clearly between `authcid` and `authzid`. `username` may be semantically equivalent to `authcid`, `authzid`, or both. * When the mechanism supports an explicit `authcid` and an `authzid`, `username` commonly refers to the `authcid`. * When the authentication identity is derived from other credentials, `username` commonly refers to the `authzid`. Every mechanism's keyword arguments, positional arguments, and documentation is updated to match this terminology. Aliases have been added from `username` to `authcid` or `authzid`—or in the other direction, from `authcid` or `authzd` to `username`. --- lib/net/imap/sasl/digest_md5_authenticator.rb | 14 +++++++--- lib/net/imap/sasl/external_authenticator.rb | 13 +++++++++- .../imap/sasl/oauthbearer_authenticator.rb | 25 ++++++++++++++++-- lib/net/imap/sasl/plain_authenticator.rb | 19 +++++++++----- lib/net/imap/sasl/scram_authenticator.rb | 10 +++++-- lib/net/imap/sasl/xoauth2_authenticator.rb | 20 +++++++++++--- test/net/imap/test_imap_authenticators.rb | 26 ++++++++++++++++++- 7 files changed, 108 insertions(+), 19 deletions(-) diff --git a/lib/net/imap/sasl/digest_md5_authenticator.rb b/lib/net/imap/sasl/digest_md5_authenticator.rb index 233bc98a..3945f155 100644 --- a/lib/net/imap/sasl/digest_md5_authenticator.rb +++ b/lib/net/imap/sasl/digest_md5_authenticator.rb @@ -20,8 +20,9 @@ class Net::IMAP::SASL::DigestMD5Authenticator # "Authentication identity" is the generic term used by # RFC-4422[https://tools.ietf.org/html/rfc4422]. # RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs abbreviate - # that to +authcid+. So +authcid+ is available as an alias for #username. + # this to +authcid+. attr_reader :username + alias authcid username # A password or passphrase that matches the #username. # @@ -44,6 +45,7 @@ class Net::IMAP::SASL::DigestMD5Authenticator # :call-seq: # new(username, password, authzid = nil, **options) -> authenticator # new(username:, password:, authzid: nil, **options) -> authenticator + # new(authcid:, password:, authzid: nil, **options) -> authenticator # # Creates an Authenticator for the "+DIGEST-MD5+" SASL mechanism. # @@ -51,9 +53,11 @@ class Net::IMAP::SASL::DigestMD5Authenticator # # ==== Parameters # - # * #username — Identity whose #password is used. - # * #password — A password or passphrase associated with this #username. + # * #authcid ― Authentication identity that is associated with #password. # + # #username ― An alias for +authcid+. + # + # * #password ― A password or passphrase associated with this #authcid. # # * _optional_ #authzid ― Authorization identity to act as or on behalf of. # @@ -65,8 +69,10 @@ class Net::IMAP::SASL::DigestMD5Authenticator # Any other keyword arguments are silently ignored. def initialize(user = nil, pass = nil, authz = nil, username: nil, password: nil, authzid: nil, + authcid: nil, warn_deprecation: true, **) - username ||= user or raise ArgumentError, "missing username" + username = authcid || username || user or + raise ArgumentError, "missing username (authcid)" password ||= pass or raise ArgumentError, "missing password" authzid ||= authz if warn_deprecation diff --git a/lib/net/imap/sasl/external_authenticator.rb b/lib/net/imap/sasl/external_authenticator.rb index 2681ef49..ab4e3502 100644 --- a/lib/net/imap/sasl/external_authenticator.rb +++ b/lib/net/imap/sasl/external_authenticator.rb @@ -25,9 +25,12 @@ class ExternalAuthenticator # imap.authenticate "PLAIN", "root", passwd, authzid: "user" # attr_reader :authzid + alias username authzid # :call-seq: # new(authzid: nil, **) -> authenticator + # new(username: nil, **) -> authenticator + # new(username = nil, **) -> authenticator # # Creates an Authenticator for the "+EXTERNAL+" SASL mechanism, as # specified in RFC-4422[https://tools.ietf.org/html/rfc4422]. To use @@ -38,8 +41,16 @@ class ExternalAuthenticator # # * _optional_ #authzid ― Authorization identity to act as or on behalf of. # + # _optional_ #username ― An alias for #authzid. + # + # Note that, unlike some other authenticators, +username+ sets the + # _authorization_ identity and not the _authentication_ identity. The + # authentication identity is established for the client by the + # external credentials. + # # Any other keyword parameters are quietly ignored. - def initialize(authzid: nil, **) + def initialize(user = nil, authzid: nil, username: nil, **) + authzid ||= username || user @authzid = authzid&.to_str&.encode "UTF-8" if @authzid&.match?(/\u0000/u) # also validates UTF8 encoding raise ArgumentError, "contains NULL" diff --git a/lib/net/imap/sasl/oauthbearer_authenticator.rb b/lib/net/imap/sasl/oauthbearer_authenticator.rb index 28f108fd..11669a9c 100644 --- a/lib/net/imap/sasl/oauthbearer_authenticator.rb +++ b/lib/net/imap/sasl/oauthbearer_authenticator.rb @@ -27,6 +27,7 @@ class OAuthAuthenticator # imap.authenticate "PLAIN", "root", passwd, authzid: "user" # attr_reader :authzid + alias username authzid # Hostname to which the client connected. (optional) attr_reader :host @@ -45,6 +46,7 @@ class OAuthAuthenticator # The query string. (optional) attr_reader :qs + alias query qs # Stores the most recent server "challenge". When authentication fails, # this may hold information about the failure reason, as JSON. @@ -61,6 +63,14 @@ class OAuthAuthenticator # #host or #port) as are specific server implementations. # # * _optional_ #authzid ― Authorization identity to act as or on behalf of. + # + # _optional_ #username — An alias for #authzid. + # + # Note that, unlike some other authenticators, +username+ sets the + # _authorization_ identity and not the _authentication_ identity. The + # authentication identity is established for the client by the OAuth + # token. + # # * _optional_ #host — Hostname to which the client connected. # * _optional_ #port — Service port to which the client connected. # * _optional_ #mthd — HTTP method @@ -68,16 +78,19 @@ class OAuthAuthenticator # * _optional_ #post — HTTP post data # * _optional_ #qs — HTTP query string # + # _optional_ #query — An alias for #qs + # # Any other keyword parameters are quietly ignored. def initialize(authzid: nil, host: nil, port: nil, + username: nil, query: nil, mthd: nil, path: nil, post: nil, qs: nil, **) - @authzid = authzid + @authzid = authzid || username @host = host @port = port @mthd = mthd @path = path @post = post - @qs = qs + @qs = qs || query @done = false end @@ -144,6 +157,14 @@ class OAuthBearerAuthenticator < OAuthAuthenticator # The most common ones are: # # * _optional_ #authzid ― Authorization identity to act as or on behalf of. + # + # _optional_ #username — An alias for #authzid. + # + # Note that, unlike some other authenticators, +username+ sets the + # _authorization_ identity and not the _authentication_ identity. The + # authentication identity is established for the client by + # #oauth2_token. + # # * _optional_ #host — Hostname to which the client connected. # * _optional_ #port — Service port to which the client connected. # diff --git a/lib/net/imap/sasl/plain_authenticator.rb b/lib/net/imap/sasl/plain_authenticator.rb index f7a0b8c8..4e794f27 100644 --- a/lib/net/imap/sasl/plain_authenticator.rb +++ b/lib/net/imap/sasl/plain_authenticator.rb @@ -22,6 +22,7 @@ class Net::IMAP::SASL::PlainAuthenticator # RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs abbreviate # this to +authcid+. attr_reader :username + alias authcid username # A password or passphrase that matches the #username. attr_reader :password @@ -42,6 +43,7 @@ class Net::IMAP::SASL::PlainAuthenticator # :call-seq: # new(username, password, authzid: nil, **) -> authenticator # new(username:, password:, authzid: nil, **) -> authenticator + # new(authcid:, password:, authzid: nil, **) -> authenticator # # Creates an Authenticator for the "+PLAIN+" SASL mechanism. # @@ -49,8 +51,11 @@ class Net::IMAP::SASL::PlainAuthenticator # # ==== Parameters # - # * #username ― Identity whose +password+ is used. - # * #password ― Password or passphrase associated with this username+. + # * #authcid ― Authentication identity that is associated with #password. + # + # #username ― An alias for #authcid. + # + # * #password ― A password or passphrase associated with the #authcid. # # * _optional_ #authzid ― Authorization identity to act as or on behalf of. # @@ -59,12 +64,14 @@ class Net::IMAP::SASL::PlainAuthenticator # # Any other keyword parameters are quietly ignored. def initialize(user = nil, pass = nil, + authcid: nil, username: nil, password: nil, authzid: nil, **) - [username, user].compact.count == 1 or - raise ArgumentError, "conflicting values for username" - [password, pass].compact.count == 1 or + [authcid, username, user].compact.count <= 1 or + raise ArgumentError, "conflicting values for username (authcid)" + [password, pass].compact.count <= 1 or raise ArgumentError, "conflicting values for password" - username ||= user or raise ArgumentError, "missing username" + username ||= authcid || user or + raise ArgumentError, "missing username (authcid)" password ||= pass or raise ArgumentError, "missing password" raise ArgumentError, "username contains NULL" if username.include?(NULL) raise ArgumentError, "password contains NULL" if password.include?(NULL) diff --git a/lib/net/imap/sasl/scram_authenticator.rb b/lib/net/imap/sasl/scram_authenticator.rb index afb1cebc..42935c68 100644 --- a/lib/net/imap/sasl/scram_authenticator.rb +++ b/lib/net/imap/sasl/scram_authenticator.rb @@ -60,6 +60,7 @@ class ScramAuthenticator # :call-seq: # new(username, password, **options) -> auth_ctx # new(username:, password:, **options) -> auth_ctx + # new(authcid:, password:, **options) -> auth_ctx # # Creates an authenticator for one of the "+SCRAM-*+" SASL mechanisms. # Each subclass defines #digest to match a specific mechanism. @@ -68,14 +69,18 @@ class ScramAuthenticator # # === Parameters # - # * #username ― Identity whose #password is used. Aliased as #authcid. + # * #authcid ― Identity whose #password is used. + # + # #username - An alias for #authcid. # * #password ― Password or passphrase associated with this #username. # * _optional_ #authzid ― Alternate identity to act as or on behalf of. # * _optional_ #min_iterations - Overrides the default value (4096). # # Any other keyword parameters are quietly ignored. def initialize(username_arg = nil, password_arg = nil, - username: nil, password: nil, authcid: nil, authzid: nil, + authcid: nil, username: nil, + authzid: nil, + password: nil, min_iterations: 4096, # see both RFC5802 and RFC7677 cnonce: nil, # must only be set in tests **options) @@ -92,6 +97,7 @@ def initialize(username_arg = nil, password_arg = nil, @min_iterations = Integer min_iterations @min_iterations.positive? or raise ArgumentError, "min_iterations must be positive" + @cnonce = cnonce || SecureRandom.base64(32) end diff --git a/lib/net/imap/sasl/xoauth2_authenticator.rb b/lib/net/imap/sasl/xoauth2_authenticator.rb index a11e1067..d20739ac 100644 --- a/lib/net/imap/sasl/xoauth2_authenticator.rb +++ b/lib/net/imap/sasl/xoauth2_authenticator.rb @@ -34,6 +34,11 @@ class Net::IMAP::SASL::XOAuth2Authenticator # relying only the identity and scope authorized by the token. attr_reader :username + # Note that, unlike most other authenticators, #username is an alias for the + # authorization identity and not the authentication identity. The + # authenticated identity is established for the client by the #oauth2_token. + alias authzid username + # An OAuth2 access token which has been authorized with the appropriate OAuth2 # scopes to use the service for #username. attr_reader :oauth2_token @@ -41,6 +46,7 @@ class Net::IMAP::SASL::XOAuth2Authenticator # :call-seq: # new(username, oauth2_token, **) -> authenticator # new(username:, oauth2_token:, **) -> authenticator + # new(authzid:, oauth2_token:, **) -> authenticator # # Creates an Authenticator for the "+XOAUTH2+" SASL mechanism, as specified by # Google[https://developers.google.com/gmail/imap/xoauth2-protocol], @@ -50,13 +56,21 @@ class Net::IMAP::SASL::XOAuth2Authenticator # === Properties # # * #username --- the username for the account being accessed. + # + # #authzid --- an alias for #username. + # + # Note that, unlike some other authenticators, +username+ sets the + # _authorization_ identity and not the _authentication_ identity. The + # authenticated identity is established for the client with the OAuth token. + # # * #oauth2_token --- An OAuth2.0 access token which is authorized to access # the service for #username. # # Any other keyword parameters are quietly ignored. - def initialize(user = nil, token = nil, username: nil, oauth2_token: nil, **) - @username = username || user or - raise ArgumentError, "missing username" + def initialize(user = nil, token = nil, username: nil, oauth2_token: nil, + authzid: nil, **) + @username = authzid || username || user or + raise ArgumentError, "missing username (authzid)" @oauth2_token = oauth2_token || token or raise ArgumentError, "missing oauth2_token" [username, user].compact.count == 1 or diff --git a/test/net/imap/test_imap_authenticators.rb b/test/net/imap/test_imap_authenticators.rb index 38773a29..38afb1fc 100644 --- a/test/net/imap/test_imap_authenticators.rb +++ b/test/net/imap/test_imap_authenticators.rb @@ -45,13 +45,34 @@ def test_plain_supports_initial_response def test_plain_response assert_equal("\0authc\0passwd", plain("authc", "passwd").process(nil)) + end + + def test_plain_authzid assert_equal("authz\0user\0pass", plain("user", "pass", authzid: "authz").process(nil)) end + def test_plain_kw_params + assert_equal( + "zid\0cid\0p", + plain(authcid: "cid", password: "p", authzid: "zid").process(nil) + ) + end + + def test_plain_username_kw_sets_both_authcid_and_authzid + assert_equal( + "\0uname\0passwd", + plain(username: "uname", password: "passwd").process(nil) + ) + end + def test_plain_no_null_chars assert_raise(ArgumentError) { plain("bad\0user", "pass") } assert_raise(ArgumentError) { plain("user", "bad\0pass") } + assert_raise(ArgumentError) { plain(authcid: "bad\0user", password: "p") } + assert_raise(ArgumentError) { plain(username: "bad\0user", password: "p") } + assert_raise(ArgumentError) { plain(username: "u", password: "bad\0pass") } + assert_raise(ArgumentError) { plain("u", "p", authzid: "bad\0authz") } assert_raise(ArgumentError) { plain("u", "p", authzid: "bad\0authz") } end @@ -244,7 +265,11 @@ def test_external_matches_mechanism def test_external_response assert_equal("", external.process(nil)) + assert_equal("", external.process("")) assert_equal("kwarg", external(authzid: "kwarg").process(nil)) + assert_equal("username", external(username: "username").process(nil)) + assert_equal("z", external("p", authzid: "z", username: "u").process(nil)) + assert_equal("positional", external("positional").process(nil)) end def test_external_utf8 @@ -256,7 +281,6 @@ def test_external_utf8 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 # ---------------------- From f36d753198c983b1fdb3ab1402cf13b3365498bc Mon Sep 17 00:00:00 2001 From: nick evans Date: Thu, 19 Oct 2023 19:16:39 -0400 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=94=92=20Update=20OAuthBearer=20to=20?= =?UTF-8?q?take=20one=20or=20two=20arguments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OAuthBearerAuthenticator` was updated to allow two arguments, in order to match the common `auth(username, secret)` parameter style. In my opinion, this makes the API a little bit confusing. But the mechanism only requires one argument, so it's natural to allow a single argument version. And this two argument version matches with how many clients and applications seem to_assume_ the SASL mechanisms _always_ work. --- lib/net/imap/sasl/oauthbearer_authenticator.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/net/imap/sasl/oauthbearer_authenticator.rb b/lib/net/imap/sasl/oauthbearer_authenticator.rb index 11669a9c..5b6d60e3 100644 --- a/lib/net/imap/sasl/oauthbearer_authenticator.rb +++ b/lib/net/imap/sasl/oauthbearer_authenticator.rb @@ -141,8 +141,9 @@ class OAuthBearerAuthenticator < OAuthAuthenticator attr_reader :oauth2_token # :call-seq: - # new(oauth2_token, **options) -> authenticator - # new(oauth2_token:, **options) -> authenticator + # new(oauth2_token, **options) -> authenticator + # new(authzid, oauth2_token, **options) -> authenticator + # new(oauth2_token:, **options) -> authenticator # # Creates an Authenticator for the "+OAUTHBEARER+" SASL mechanism. # @@ -172,8 +173,9 @@ class OAuthBearerAuthenticator < OAuthAuthenticator # noting that application protocols are allowed to # require #authzid (or other parameters, such as #host # _or_ #port) as are specific server implementations. - def initialize(oauth2_token_arg = nil, oauth2_token: nil, **args, &blk) - super(**args, &blk) # handles authzid, host, port, etc + def initialize(arg1 = nil, arg2 = nil, oauth2_token: nil, **args, &blk) + username, oauth2_token_arg = arg2.nil? ? [nil, arg1] : [arg1, arg2] + super(username: username, **args, &blk) oauth2_token && oauth2_token_arg and raise ArgumentError, "conflicting values for oauth2_token" @oauth2_token = oauth2_token || oauth2_token_arg or From 7629c21b5e8fedf47f30aeb185393c1ba3932de9 Mon Sep 17 00:00:00 2001 From: nick evans Date: Thu, 19 Oct 2023 19:09:29 -0400 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=94=92=20Don't=20raise=20ArgumentErro?= =?UTF-8?q?r=20for=20overriding=20args?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of raising an exception, conflicting arguments are silently ignored. This allows more specific argument (like `authcid` or a keyword argument) to be used like an override to more generic terms (like `username` or a positional argument). It should also simplify using a single set of keyword credentials with multiple SASL mechanisms, which could facilitate dynamically negotiating the mechanism, or automatically retrying multiple acceptable mechanisms. --- lib/net/imap/sasl/oauthbearer_authenticator.rb | 2 -- lib/net/imap/sasl/plain_authenticator.rb | 4 ---- lib/net/imap/sasl/scram_authenticator.rb | 4 ---- lib/net/imap/sasl/xoauth2_authenticator.rb | 4 ---- 4 files changed, 14 deletions(-) diff --git a/lib/net/imap/sasl/oauthbearer_authenticator.rb b/lib/net/imap/sasl/oauthbearer_authenticator.rb index 5b6d60e3..f7191c71 100644 --- a/lib/net/imap/sasl/oauthbearer_authenticator.rb +++ b/lib/net/imap/sasl/oauthbearer_authenticator.rb @@ -176,8 +176,6 @@ class OAuthBearerAuthenticator < OAuthAuthenticator def initialize(arg1 = nil, arg2 = nil, oauth2_token: nil, **args, &blk) username, oauth2_token_arg = arg2.nil? ? [nil, arg1] : [arg1, arg2] super(username: username, **args, &blk) - 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 diff --git a/lib/net/imap/sasl/plain_authenticator.rb b/lib/net/imap/sasl/plain_authenticator.rb index 4e794f27..cb1acf24 100644 --- a/lib/net/imap/sasl/plain_authenticator.rb +++ b/lib/net/imap/sasl/plain_authenticator.rb @@ -66,10 +66,6 @@ class Net::IMAP::SASL::PlainAuthenticator def initialize(user = nil, pass = nil, authcid: nil, username: nil, password: nil, authzid: nil, **) - [authcid, username, user].compact.count <= 1 or - raise ArgumentError, "conflicting values for username (authcid)" - [password, pass].compact.count <= 1 or - raise ArgumentError, "conflicting values for password" username ||= authcid || user or raise ArgumentError, "missing username (authcid)" password ||= pass or raise ArgumentError, "missing password" diff --git a/lib/net/imap/sasl/scram_authenticator.rb b/lib/net/imap/sasl/scram_authenticator.rb index 42935c68..01c63480 100644 --- a/lib/net/imap/sasl/scram_authenticator.rb +++ b/lib/net/imap/sasl/scram_authenticator.rb @@ -86,12 +86,8 @@ def initialize(username_arg = nil, password_arg = nil, **options) @username = username || username_arg || authcid or raise ArgumentError, "missing username (authcid)" - [username, username_arg, authcid].compact.count == 1 or - raise ArgumentError, "conflicting values for username (authcid)" @password = password || password_arg or raise ArgumentError, "missing password" - [password, password_arg].compact.count == 1 or - raise ArgumentError, "conflicting values for password" @authzid = authzid @min_iterations = Integer min_iterations diff --git a/lib/net/imap/sasl/xoauth2_authenticator.rb b/lib/net/imap/sasl/xoauth2_authenticator.rb index d20739ac..819b42ff 100644 --- a/lib/net/imap/sasl/xoauth2_authenticator.rb +++ b/lib/net/imap/sasl/xoauth2_authenticator.rb @@ -73,10 +73,6 @@ def initialize(user = nil, token = nil, username: nil, oauth2_token: nil, raise ArgumentError, "missing username (authzid)" @oauth2_token = oauth2_token || token or raise ArgumentError, "missing oauth2_token" - [username, user].compact.count == 1 or - raise ArgumentError, "conflicting values for username" - [oauth2_token, token].compact.count == 1 or - raise ArgumentError, "conflicting values for oauth2_token" @done = false end From 34e466288734f44239a4e9ec8f0db4a64a140e91 Mon Sep 17 00:00:00 2001 From: nick evans Date: Sat, 14 Oct 2023 18:30:31 -0400 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=94=92=20Add=20kwargs=20to=20deprecat?= =?UTF-8?q?ed=20+LOGIN+=20and=20+CRAM-MD5+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are added to give _all_ mechanisms the same basic argument semantics. If it were just for `Net::IMAP`, I'd say it's not worth it. However, to make this `SASL` implementation fully compatible with others—such as `net-smtp`. --- lib/net/imap/sasl/cram_md5_authenticator.rb | 10 +++++++--- lib/net/imap/sasl/login_authenticator.rb | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/net/imap/sasl/cram_md5_authenticator.rb b/lib/net/imap/sasl/cram_md5_authenticator.rb index 3aac7b35..d5648515 100644 --- a/lib/net/imap/sasl/cram_md5_authenticator.rb +++ b/lib/net/imap/sasl/cram_md5_authenticator.rb @@ -14,13 +14,17 @@ # of cleartext and recommends TLS version 1.2 or greater be used for all # traffic. With TLS +CRAM-MD5+ is okay, but so is +PLAIN+ class Net::IMAP::SASL::CramMD5Authenticator - def initialize(user, password, warn_deprecation: true, **_ignored) + def initialize(user = nil, pass = nil, + authcid: nil, username: nil, + password: nil, + warn_deprecation: true, + **) if warn_deprecation warn "WARNING: CRAM-MD5 mechanism is deprecated." # TODO: recommend SCRAM end require "digest/md5" - @user = user - @password = password + @user = authcid || username || user + @password = password || pass @done = false end diff --git a/lib/net/imap/sasl/login_authenticator.rb b/lib/net/imap/sasl/login_authenticator.rb index 5132a09e..81201f66 100644 --- a/lib/net/imap/sasl/login_authenticator.rb +++ b/lib/net/imap/sasl/login_authenticator.rb @@ -23,12 +23,16 @@ class Net::IMAP::SASL::LoginAuthenticator STATE_DONE = :DONE private_constant :STATE_USER, :STATE_PASSWORD, :STATE_DONE - def initialize(user, password, warn_deprecation: true, **_ignored) + def initialize(user = nil, pass = nil, + authcid: nil, username: nil, + password: nil, + warn_deprecation: true, + **) if warn_deprecation warn "WARNING: LOGIN SASL mechanism is deprecated. Use PLAIN instead." end - @user = user - @password = password + @user = authcid || username || user + @password = password || pass @state = STATE_USER end