Skip to content

Commit ef3b20e

Browse files
committed
✨ SASL OAUTHBEARER: Add mechanism [🚧tests]
1 parent a0dbdc0 commit ef3b20e

File tree

4 files changed

+242
-1
lines changed

4 files changed

+242
-1
lines changed

lib/net/imap/authenticators.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def authenticators
101101
require_relative "sasl/xoauth2_authenticator"
102102
require_relative "sasl/anonymous_authenticator"
103103
require_relative "sasl/external_authenticator"
104+
require_relative "sasl/oauthbearer_authenticator"
104105

105106
# deprecated
106107
require_relative "sasl/login_authenticator"

lib/net/imap/sasl/authenticator.rb

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

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)