Skip to content

Commit 2e76f01

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 a0ede93 commit 2e76f01

File tree

6 files changed

+286
-1
lines changed

6 files changed

+286
-1
lines changed

lib/net/imap.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,13 @@ def starttls(options = {}, verify = true)
10021002
# Each mechanism has different properties and requirements. Please consult
10031003
# the documentation for the specific mechanisms you are using:
10041004
#
1005+
# +OAUTHBEARER+::
1006+
# See OAuthBearerAuthenticator[rdoc-ref:Net::IMAP::SASL::OAuthBearerAuthenticator].
1007+
#
1008+
# Login using an OAuth2 Bearer token. This is the standard mechanism
1009+
# for using OAuth2 with \SASL, but it is not yet deployed as widely as
1010+
# +XOAUTH2+.
1011+
#
10051012
# +PLAIN+::
10061013
# See PlainAuthenticator[rdoc-ref:Net::IMAP::SASL::PlainAuthenticator].
10071014
#

lib/net/imap/sasl.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ class IMAP
2727
# Each mechanism has different properties and requirements. Please consult
2828
# the documentation for the specific mechanisms you are using:
2929
#
30+
# +OAUTHBEARER+::
31+
# See OAuthBearerAuthenticator.
32+
#
33+
# Login using an OAuth2 Bearer token. This is the standard mechanism
34+
# for using OAuth2 with \SASL, but it is not yet deployed as widely as
35+
# +XOAUTH2+.
36+
#
3037
# +PLAIN+::
3138
# See PlainAuthenticator.
3239
#
@@ -69,7 +76,8 @@ module SASL
6976

7077
sasl_dir = File.expand_path("sasl", __dir__)
7178
autoload :Authenticators, "#{sasl_dir}/authenticators"
72-
79+
autoload :GS2Header, "#{sasl_dir}/gs2_header"
80+
autoload :OAuthBearerAuthenticator, "#{sasl_dir}/oauthbearer_authenticator"
7381
autoload :PlainAuthenticator, "#{sasl_dir}/plain_authenticator"
7482
autoload :XOAuth2Authenticator, "#{sasl_dir}/xoauth2_authenticator"
7583

lib/net/imap/sasl/authenticators.rb

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

test/net/imap/test_imap_authenticators.rb

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

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

0 commit comments

Comments
 (0)