Skip to content

Commit 8d49eeb

Browse files
committed
🔒 Add SASL OAUTHBEARER mechanism
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-*`.
1 parent 1020e4c commit 8d49eeb

File tree

6 files changed

+297
-1
lines changed

6 files changed

+297
-1
lines changed

lib/net/imap.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,13 @@ def starttls(options = {}, verify = true)
10141014
# Login using already established credentials, such as a TLS certificate
10151015
# or IPsec.
10161016
#
1017+
# +OAUTHBEARER+::
1018+
# See OAuthBearerAuthenticator[Net::IMAP::SASL::OAuthBearerAuthenticator].
1019+
#
1020+
# Login using an OAuth2 Bearer token. This is the standard mechanism
1021+
# for using OAuth2 with \SASL, but it is not yet deployed as widely as
1022+
# +XOAUTH2+.
1023+
#
10171024
# +PLAIN+::
10181025
# See PlainAuthenticator[Net::IMAP::SASL::PlainAuthenticator].
10191026
#

lib/net/imap/sasl.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ class IMAP
3939
# Login using already established credentials, such as a TLS certificate
4040
# or IPsec.
4141
#
42+
# +OAUTHBEARER+::
43+
# See OAuthBearerAuthenticator[Net::IMAP::SASL::OAuthBearerAuthenticator].
44+
#
45+
# Login using an OAuth2 Bearer token. This is the standard mechanism
46+
# for using OAuth2 with \SASL, but it is not yet deployed as widely as
47+
# +XOAUTH2+.
48+
#
4249
# +PLAIN+::
4350
# See PlainAuthenticator[Net::IMAP::SASL::PlainAuthenticator].
4451
#
@@ -81,9 +88,10 @@ module SASL
8188

8289
sasl_dir = File.expand_path("sasl", __dir__)
8390
autoload :Authenticators, "#{sasl_dir}/authenticators"
84-
91+
autoload :GS2Header, "#{sasl_dir}/gs2_header"
8592
autoload :AnonymousAuthenticator, "#{sasl_dir}/anonymous_authenticator"
8693
autoload :ExternalAuthenticator, "#{sasl_dir}/external_authenticator"
94+
autoload :OAuthBearerAuthenticator, "#{sasl_dir}/oauthbearer_authenticator"
8795
autoload :PlainAuthenticator, "#{sasl_dir}/plain_authenticator"
8896
autoload :XOAuth2Authenticator, "#{sasl_dir}/xoauth2_authenticator"
8997

lib/net/imap/sasl/authenticators.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def initialize(use_defaults: false)
3535
if use_defaults
3636
add_authenticator "Anonymous"
3737
add_authenticator "External"
38+
add_authenticator "OAuthBearer"
3839
add_authenticator "Plain"
3940
add_authenticator "XOAuth2"
4041
add_authenticator "Login" # deprecated

lib/net/imap/sasl/gs2_header.rb

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP < Protocol
5+
module SASL
6+
7+
# Several mechanisms start with a GS2 header:
8+
# * +GS2-*+
9+
# * +SCRAM-*+ --- ScramAuthenticator
10+
# * +OPENID20+
11+
# * +SAML20+
12+
# * +OAUTH10A+
13+
# * +OAUTHBEARER+ --- OAuthBearerAuthenticator
14+
#
15+
# Classes that include this must implement +#authzid+.
16+
module GS2Header
17+
NO_NULL_CHARS = /\A[^\x00]+\z/u.freeze # :nodoc:
18+
19+
##
20+
# Matches {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
21+
# +saslname+. The output from gs2_saslname_encode matches this Regexp.
22+
RFC5801_SASLNAME = /\A(?:[^,=\x00]|=2C|=3D)+\z/u.freeze
23+
24+
# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
25+
# +gs2-header+, which prefixes the #initial_client_response.
26+
#
27+
# >>>
28+
# <em>Note: the actual GS2 header includes an optional flag to
29+
# indicate that the GSS mechanism is not "standard", but since all of
30+
# the SASL mechanisms using GS2 are "standard", we don't include that
31+
# flag. A class for a nonstandard GSSAPI mechanism should prefix with
32+
# "+F,+".</em>
33+
def gs2_header
34+
"#{gs2_cb_flag},#{gs2_authzid},"
35+
end
36+
37+
# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
38+
# +gs2-cb-flag+:
39+
#
40+
# "+n+":: The client doesn't support channel binding.
41+
# "+y+":: The client does support channel binding
42+
# but thinks the server does not.
43+
# "+p+":: The client requires channel binding.
44+
# The selected channel binding follows "+p=+".
45+
#
46+
# The default always returns "+n+". A mechanism that supports channel
47+
# binding must override this method.
48+
#
49+
def gs2_cb_flag; "n" end
50+
51+
# The {RFC5801 §4}[https://www.rfc-editor.org/rfc/rfc5801#section-4]
52+
# +gs2-authzid+ header, when +#authzid+ is not empty.
53+
#
54+
# If +#authzid+ is empty or +nil+, an empty string is returned.
55+
def gs2_authzid
56+
return "" if authzid.nil? || authzid == ""
57+
"a=#{gs2_saslname_encode(authzid)}"
58+
end
59+
60+
module_function
61+
62+
# Encodes +str+ to match RFC5801_SASLNAME.
63+
#
64+
#--
65+
# TODO: validate NO_NULL_CHARS and valid UTF-8 in the attr_writer.
66+
def gs2_saslname_encode(str)
67+
str = str.encode("UTF-8")
68+
# Regexp#match raises "invalid byte sequence" for invalid UTF-8
69+
NO_NULL_CHARS.match str or
70+
raise ArgumentError, "invalid saslname: %p" % [str]
71+
str
72+
.gsub(?=, "=3D")
73+
.gsub(?,, "=2C")
74+
end
75+
76+
end
77+
end
78+
end
79+
end
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "gs2_header"
4+
5+
module Net
6+
class IMAP < Protocol
7+
module SASL
8+
9+
# Abstract base class for the SASL mechanisms defined in
10+
# RFC7628[https://tools.ietf.org/html/rfc7628]:
11+
# * OAUTHBEARER[rdoc-ref:OAuthBearerAuthenticator]
12+
# * OAUTH10A
13+
class OAuthAuthenticator
14+
include GS2Header
15+
16+
# Creates an RFC7628[https://tools.ietf.org/html/rfc7628] OAuth
17+
# authenticator.
18+
#
19+
# === Configuration parameters
20+
#
21+
# See child classes for required configuration parameter(s). The
22+
# following parameters are all optional, but protocols or servers may
23+
# add requirements for #authzid, #host, #port, or any other parameter.
24+
#
25+
# * #authzid ― Identity to act as or on behalf of.
26+
# * #host — Hostname to which the client connected.
27+
# * #port — Service port to which the client connected.
28+
# * #mthd — HTTP method
29+
# * #path — HTTP path data
30+
# * #post — HTTP post data
31+
# * #qs — HTTP query string
32+
#
33+
def initialize(authzid: nil, host: nil, port: nil,
34+
mthd: nil, path: nil, post: nil, qs: nil, **)
35+
@authzid = authzid
36+
@host = host
37+
@port = port
38+
@mthd = mthd
39+
@path = path
40+
@post = post
41+
@qs = qs
42+
@done = false
43+
end
44+
45+
# Authorization identity: an identity to act as or on behalf of.
46+
#
47+
# If no explicit authorization identity is provided, it is usually
48+
# derived from the authentication identity. For the OAuth-based
49+
# mechanisms, the authentication identity is the identity established by
50+
# the OAuth credential.
51+
#
52+
# See also: PlainAuthenticator#authzid, DigestMD5Authenticator#authzid.
53+
attr_reader :authzid
54+
55+
##
56+
# Hostname to which the client connected.
57+
attr_reader :host
58+
59+
##
60+
# Service port to which the client connected.
61+
attr_reader :port
62+
63+
##
64+
# HTTP method. (optional)
65+
attr_reader :mthd
66+
67+
##
68+
# HTTP path data. (optional)
69+
attr_reader :path
70+
71+
##
72+
# HTTP post data. (optional)
73+
attr_reader :post
74+
75+
##
76+
# The query string. (optional)
77+
attr_reader :qs
78+
79+
# Stores the most recent server "challenge". When authentication fails,
80+
# this may hold information about the failure reason, as JSON.
81+
attr_reader :last_server_response
82+
83+
##
84+
# Returns initial_client_response the first time, then "<tt>^A</tt>".
85+
def process(data)
86+
@last_server_response = data
87+
return "\1" if done?
88+
initial_client_response
89+
ensure
90+
@done = true
91+
end
92+
93+
##
94+
# Returns true when the initial client response was sent.
95+
#
96+
# The authentication should not succeed unless this returns true, but it
97+
# does *not* indicate success.
98+
def done?; @done end
99+
100+
# The {RFC7628 §3.1}[https://www.rfc-editor.org/rfc/rfc7628#section-3.1]
101+
# formatted response.
102+
def initial_client_response
103+
[gs2_header, *kv_pairs.map {|kv| kv.join("=") }, "\1"].join("\1")
104+
end
105+
106+
# The key value pairs which follow gs2_header, as a Hash.
107+
def kv_pairs
108+
{
109+
host: host, port: port, mthd: mthd, path: path, post: post, qs: qs,
110+
auth: authorization, # authorization is implemented by subclasses
111+
}.compact
112+
end
113+
114+
# Value of the HTTP Authorization header
115+
#
116+
# <b>Implemented by subclasses.</b>
117+
def authorization; raise "must be implemented by subclass" end
118+
119+
end
120+
121+
# Authenticator for the "+OAUTHBEARER+" SASL mechanism, specified in
122+
# RFC7628[https://tools.ietf.org/html/rfc7628]. Authenticates using OAuth
123+
# 2.0 bearer tokens, as described in
124+
# RFC6750[https://tools.ietf.org/html/rfc6750]. Use via
125+
# Net::IMAP#authenticate.
126+
#
127+
# RFC6750 requires Transport Layer Security (TLS) [RFC5246] to secure the
128+
# protocol interaction between the client and the resource server. TLS
129+
# _MUST_ be used for +OAUTHBEARER+ to protect the bearer token.
130+
class OAuthBearerAuthenticator < OAuthAuthenticator
131+
132+
##
133+
# :call-seq:
134+
# new(oauth2_token, authzid: nil, **) -> auth_ctx
135+
# new(oauth2_token:, authzid: nil, **) -> auth_ctx
136+
# new(**) {|propname, auth_ctx| propval } -> auth_ctx
137+
#
138+
# Creates an Authenticator for the "+OAUTHBEARER+" SASL mechanism.
139+
#
140+
# Called by Net::IMAP#authenticate and similar methods on other clients.
141+
#
142+
# === Configuration parameters
143+
#
144+
# * #oauth2_token — An OAuth2 bearer token or access token. *Required*
145+
# May be provided as either regular or keyword argument.
146+
# * #authzid ― Identity to act as or on behalf of.
147+
# * #host — Hostname to which the client connected.
148+
# * #port — Service port to which the client connected.
149+
# * See OAuthAuthenticator documentation for less common parameters.
150+
#
151+
# Only #oauth2_token is required by the mechanism, but servers may add
152+
# requirements for #authzid, #host, #port, or any other parameter.
153+
#
154+
def initialize(oauth2_token_arg = nil, oauth2_token: nil, **args)
155+
super(**args) # handles authzid, host, port, callback, etc
156+
[oauth2_token, oauth2_token_arg].compact.count <= 1 or
157+
raise ArgumentError, "conflicting values for oauth2_token"
158+
@oauth2_token = oauth2_token || oauth2_token_arg or
159+
raise ArgumentError, "missing oauth2_token"
160+
end
161+
162+
##
163+
# An OAuth2 bearer token, generally the access token.
164+
attr_reader :oauth2_token
165+
166+
# :call-seq:
167+
# initial_response? -> true
168+
#
169+
# +OAUTHBEARER+ sends an initial client response.
170+
def initial_response?; true end
171+
172+
# Value of the HTTP Authorization header
173+
def authorization; "Bearer #{oauth2_token}" end
174+
175+
end
176+
end
177+
178+
end
179+
end

test/net/imap/test_imap_authenticators.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,28 @@ def test_plain_no_null_chars
5858
assert_raise(ArgumentError) { plain("u", "p", authzid: "bad\0authz") }
5959
end
6060

61+
# ----------------------
62+
# OAUTHBEARER
63+
# ----------------------
64+
65+
def test_oauthbearer_authenticator_matches_mechanism
66+
assert_kind_of(Net::IMAP::SASL::OAuthBearerAuthenticator,
67+
Net::IMAP::SASL.authenticator("OAUTHBEARER", "tok"))
68+
end
69+
70+
def oauthbearer(*args, **kwargs, &block)
71+
Net::IMAP::SASL.authenticator("OAUTHBEARER", *args, **kwargs, &block)
72+
end
73+
74+
def test_oauthbearer_response
75+
assert_equal(
76+
"n,[email protected],\1host=server.example.com\1port=587\1" \
77+
"auth=Bearer mF_9.B5f-4.1JqM\1\1",
78+
oauthbearer("mF_9.B5f-4.1JqM", authzid: "[email protected]",
79+
host: "server.example.com", port: 587).process(nil)
80+
)
81+
end
82+
6183
# ----------------------
6284
# XOAUTH2
6385
# ----------------------

0 commit comments

Comments
 (0)