Skip to content

Commit

Permalink
✨ SASL OAUTHBEARER: Add mechanism [🚧 more tests]
Browse files Browse the repository at this point in the history
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-*`.
  • Loading branch information
nevans committed Aug 5, 2023
1 parent 920e6be commit f22fe34
Show file tree
Hide file tree
Showing 4 changed files with 301 additions and 0 deletions.
8 changes: 8 additions & 0 deletions lib/net/imap/sasl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand All @@ -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
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

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

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

0 comments on commit f22fe34

Please sign in to comment.