Skip to content

Commit c1dd6be

Browse files
committed
✨ SASL OAUTHBEARER: Add mechanism [🚧 more tests]
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-*`.
1 parent f5c9039 commit c1dd6be

File tree

6 files changed

+310
-0
lines changed

6 files changed

+310
-0
lines changed

lib/net/imap.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,11 @@ def starttls(options = {}, verify = true)
976976
# +PLAIN+:: See SASL::PlainAuthenticator.
977977
# Login using clear-text username and password.
978978
#
979+
# +OAUTHBEARER+:: See SASL::OAuthBearerAuthenticator.
980+
# Login using an OAUTH2 Bearer token. This is the
981+
# standard mechanism for using OAuth2 with \SASL, but it
982+
# is not yet deployed as widely as +XOAUTH2+.
983+
#
979984
# +XOAUTH2+:: See SASL::XOAuth2Authenticator.
980985
# Login using a username and OAuth2 access token.
981986
# Non-standard and obsoleted by +OAUTHBEARER+, but widely

lib/net/imap/authenticators.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def authenticators
6565

6666
require_relative "sasl/anonymous_authenticator"
6767
require_relative "sasl/external_authenticator"
68+
require_relative "sasl/oauthbearer_authenticator"
6869
require_relative "sasl/plain_authenticator"
6970
require_relative "sasl/xoauth2_authenticator"
7071

lib/net/imap/sasl/authenticator.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ module SASL
2222
# +PLAIN+:: See SASL::PlainAuthenticator.
2323
# Login using clear-text username and password.
2424
#
25+
# +OAUTHBEARER+:: See SASL::OAuthBearerAuthenticator.
26+
# Login using an OAUTH2 Bearer token. This is the
27+
# standard mechanism for using OAuth2 with \SASL, but it
28+
# is not yet deployed as widely as +XOAUTH2+.
29+
#
2530
# +XOAUTH2+:: See SASL::XOAuth2Authenticator.
2631
# Login using a username and OAuth2 access token.
2732
# Non-standard and obsoleted by +OAUTHBEARER+, but widely

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: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "authenticator"
4+
require_relative "gs2_header"
5+
6+
module Net
7+
class IMAP < Protocol
8+
module SASL
9+
10+
# Abstract base class for the SASL mechanisms defined in
11+
# RFC7628[https://tools.ietf.org/html/rfc7628]:
12+
# * OAUTHBEARER[rdoc-ref:OAuthBearerAuthenticator]
13+
# * OAUTH10A
14+
class OAuthAuthenticator < Authenticator
15+
include GS2Header
16+
17+
# <b>Implemented by subclasses.</b>
18+
def self.mechanism_name; raise NotImplementedError end
19+
20+
# Creates an OAuthBearerAuthenticator or OAuth10aAuthenticator.
21+
#
22+
# * +_subclass_var_+ — the subclass's required parameter.
23+
# * #authzid ― Identity to act as or on behalf of.
24+
# * #host — Hostname to which the client connected.
25+
# * #port — Service port to which the client connected.
26+
# * #mthd — HTTP method
27+
# * #path — HTTP path data
28+
# * #post — HTTP post data
29+
# * #qs — HTTP query string
30+
#
31+
# All properties here are optional. See the child classes for their
32+
# required parameter(s).
33+
#
34+
def initialize(arg1_authzid = nil, _ = nil, arg3_authzid = nil,
35+
authzid: nil, host: nil, port: nil,
36+
mthd: nil, path: nil, post: nil, qs: nil, **)
37+
super
38+
propinit(:authzid, authzid, arg1_authzid, arg3_authzid)
39+
self.host = host
40+
self.port = port
41+
self.mthd = mthd
42+
self.path = path
43+
self.post = post
44+
self.qs = qs
45+
@done = false
46+
end
47+
48+
##
49+
# Authorization identity: an identity to act as or on behalf of.
50+
#
51+
# For the OAuth-based mechanisms, authcid is implicitly set by the
52+
# #auth_payload. It may be useful to make it explicit, which allows the
53+
# server to verify the credentials match the identity. The gs2_header
54+
# MAY include the username associated with the resource being accessed,
55+
# the "authzid".
56+
#
57+
# It is worth noting that application protocols are allowed to require
58+
# an authzid, as are specific server implementations.
59+
#
60+
# See also: PlainAuthenticator#authzid, DigestMD5Authenticator#authzid.
61+
property :authzid
62+
63+
##
64+
# Hostname to which the client connected.
65+
property :host
66+
67+
##
68+
# Service port to which the client connected.
69+
property :port
70+
71+
##
72+
# HTTP method. (optional)
73+
property :mthd
74+
75+
##
76+
# HTTP path data. (optional)
77+
property :path
78+
79+
##
80+
# HTTP post data. (optional)
81+
property :post
82+
83+
##
84+
# The query string. (optional)
85+
property :qs
86+
87+
# Stores the most recent server "challenge". When authentication fails,
88+
# this may hold information about the failure reason, as JSON.
89+
attr_reader :last_server_response
90+
91+
##
92+
# Returns initial_client_response the first time, then "<tt>^A</tt>".
93+
def process(data)
94+
@last_server_response = data
95+
return "\1" if done?
96+
initial_client_response
97+
ensure
98+
@done = true
99+
end
100+
101+
##
102+
# Returns true when the initial client response was sent.
103+
#
104+
# The authentication should not succeed unless this returns true, but it
105+
# does *not* indicate success.
106+
def done?; @done end
107+
108+
# The {RFC7628 §3.1}[https://www.rfc-editor.org/rfc/rfc7628#section-3.1]
109+
# formatted response.
110+
def initial_client_response
111+
[gs2_header, *kv_pairs.map {|kv| kv.join("=") }, "\1"].join("\1")
112+
end
113+
114+
# The key value pairs which follow gs2_header, as a Hash.
115+
def kv_pairs
116+
{
117+
host: host, port: port, mthd: mthd, path: path, post: post, qs: qs,
118+
auth: auth_payload, # auth_payload is implemented by subclasses
119+
}.compact
120+
end
121+
122+
# What would be sent in the HTTP Authorization header.
123+
#
124+
# <b>Implemented by subclasses.</b>
125+
def auth_payload; raise NotImplementedError, "implement in subclass" end
126+
127+
end
128+
129+
# Authenticator for the "+OAUTHBEARER+" SASL mechanism, specified in
130+
# RFC7628[https://tools.ietf.org/html/rfc7628]. Use via
131+
# Net::IMAP#authenticate.
132+
#
133+
# TODO...
134+
#
135+
# OAuth 2.0 bearer tokens, as described in [RFC6750].
136+
# RFC6750 uses Transport Layer Security (TLS) [RFC5246] to
137+
# secure the protocol interaction between the client and the
138+
# resource server.
139+
#
140+
# TLS MUST be used for +OAUTHBEARER+ to protect the bearer token.
141+
class OAuthBearerAuthenticator < OAuthAuthenticator
142+
# +OAUTHBEARER+
143+
def self.mechanism_name; "OAUTHBEARER" end
144+
register Net::IMAP
145+
146+
##
147+
# :call-seq:
148+
# new(authzid, oauth2_token, **) -> auth_ctx
149+
# new(oauth2_token:, authzid: nil, **) -> auth_ctx
150+
# new(**) {|propname, auth_ctx| propval } -> auth_ctx
151+
#
152+
# Creates an Authenticator for the "+OAUTHBEARER+" SASL mechanism.
153+
#
154+
# Called by Net::IMAP#authenticate and similar methods on other clients.
155+
#
156+
# === Properties
157+
#
158+
# * #oauth2_token — An OAuth2 bearer token or access token. *Required*
159+
# * #authzid ― Identity to act as or on behalf of.
160+
# * #host — Hostname to which the client connected.
161+
# * #port — Service port to which the client connected.
162+
# * See other, rarely used properties on OAuthAuthenticator.
163+
#
164+
# Although only #oauth2_token is required, specific server
165+
# implementations may additionally require #authzid, #host, and #port.
166+
#
167+
# See the documentation on each property method for more details.
168+
#
169+
# All three properties may be sent as either positional or keyword
170+
# arguments. See Net::IMAP::SASL::Authenticator@Properties for a
171+
# detailed description of property assignment, lazy loading, and
172+
# callbacks.
173+
#
174+
def initialize(id1=nil, arg2_token=nil, id3=nil, oauth2_token: nil, **)
175+
super # handles authzid, host, port, callback, etc
176+
propinit(:oauth2_token, oauth2_token, arg2_token, required: true)
177+
self.host = host
178+
end
179+
180+
##
181+
# An OAuth2 bearer token, which is generally the same as the standard
182+
# access_token.
183+
property :oauth2_token
184+
185+
# :call-seq:
186+
# initial_response? -> true
187+
#
188+
# +OAUTHBEARER+ sends an initial client response.
189+
def initial_response?; true end
190+
191+
# What would be sent in the HTTP Authorization header.
192+
def auth_payload; "Bearer #{oauth2_token}" end
193+
194+
end
195+
end
196+
197+
end
198+
end

test/net/imap/sasl/test_authenticators.rb

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

34+
# ----------------------
35+
# OAUTHBEARER
36+
# ----------------------
37+
38+
def test_oauthbearer_authenticator_matches_mechanism
39+
assert_kind_of(Net::IMAP::SASL::OAuthBearerAuthenticator,
40+
Net::IMAP.authenticator("OAUTHBEARER", nil, "tok"))
41+
end
42+
43+
def oauthbearer(*args, **kwargs, &block)
44+
Net::IMAP.authenticator("OAUTHBEARER", *args, **kwargs, &block)
45+
end
46+
47+
def test_oauthbearer_response
48+
assert_equal(
49+
"n,[email protected],\1host=server.example.com\1port=587\1" \
50+
"auth=Bearer mF_9.B5f-4.1JqM\1\1",
51+
oauthbearer("[email protected]", "mF_9.B5f-4.1JqM",
52+
host: "server.example.com", port: 587).process(nil)
53+
)
54+
end
55+
3456
# ----------------------
3557
# XOAUTH2
3658
# ----------------------

0 commit comments

Comments
 (0)