Skip to content

Commit

Permalink
🔒 Add SASL OAUTHBEARER mechanism
Browse files Browse the repository at this point in the history
Also, GS2Header was extracted from OAuthBearerAuthenticator.  It's not
much, but it can be re-used in the implementation of other mechanisms,
e.g. `SCRAM-SHA-*`.
  • Loading branch information
nevans committed Sep 15, 2023
1 parent a0ede93 commit ba3a572
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 1 deletion.
7 changes: 7 additions & 0 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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].
#
Expand Down
10 changes: 9 additions & 1 deletion lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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"

Expand Down
1 change: 1 addition & 0 deletions lib/net/imap/sasl/authenticators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions lib/net/imap/sasl/gs2_header.rb
Original file line number Diff line number Diff line change
@@ -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.
#
# >>>
# <em>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,+".</em>
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
166 changes: 166 additions & 0 deletions lib/net/imap/sasl/oauthbearer_authenticator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# 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.
#
# 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 "<tt>^A</tt>".
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
#
# <b>Implemented by subclasses.</b>
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
22 changes: 22 additions & 0 deletions test/net/imap/test_imap_authenticators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,[email protected],\1host=server.example.com\1port=587\1" \
"auth=Bearer mF_9.B5f-4.1JqM\1\1",
oauthbearer("mF_9.B5f-4.1JqM", authzid: "[email protected]",
host: "server.example.com", port: 587).process(nil)
)
end

# ----------------------
# XOAUTH2
# ----------------------
Expand Down

0 comments on commit ba3a572

Please sign in to comment.