Skip to content

Commit 43d260a

Browse files
committed
🔒 SASL XOAUTH2: document, kwargs
1 parent 28192a0 commit 43d260a

File tree

2 files changed

+107
-11
lines changed

2 files changed

+107
-11
lines changed
Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,103 @@
11
# frozen_string_literal: true
22

3+
# Authenticator for the "+XOAUTH2+" SASL mechanism. This mechanism was
4+
# originally created for GMail and widely adopted by hosted email providers.
5+
# +XOAUTH2+ has been documented by
6+
# Google[https://developers.google.com/gmail/imap/xoauth2-protocol],
7+
# Microsoft[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth],
8+
# and Yahoo[https://senders.yahooinc.com/developer/documentation].
9+
#
10+
# This mechanism requires an OAuth2 +access_token+ which has been authorized
11+
# with the appropriate OAuth2 scopes to access IMAP. These scopes are not
12+
# standardized---consult each email service provider's documentation.
13+
#
14+
# Although this mechanism was never standardized and has been obsoleted by
15+
# "+OAUTHBEARER+", it is still very widely supported.
316
class Net::IMAP::SASL::XOAuth2Authenticator
417

18+
##
19+
# :call-seq:
20+
# new(username, oauth2_token, **) -> auth_ctx
21+
# new(authcid:, oauth2_token:, **) -> auth_ctx
22+
# new(**) {|propname, auth_ctx| propval } -> auth_ctx
23+
#
24+
# Creates an Authenticator for the "+XOAUTH2+" SASL mechanism, as specified by
25+
# Google[https://developers.google.com/gmail/imap/xoauth2-protocol], and also
26+
# documented by
27+
# Microsoft[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth],
28+
# and Yahoo[https://senders.yahooinc.com/developer/documentation]. To use
29+
# this, see Net::IMAP#authenticate or your client's authentication method.
30+
#
31+
# === Properties
32+
#
33+
# Both properties may be sent either as positional arguments or as keyword
34+
# arguments. If the same attribute is sent as both a positional arg and a
35+
# keyword arg, an ArgumentError is raised.
36+
#
37+
# * #authcid, #username --- the identity whose #password is used.
38+
# Only <tt>:authcid</tt> will be sent to callbacks.
39+
# * #oauth2_token --- An OAuth2.0 access token assigned to #username,
40+
# authorized to access IMAP.
41+
#
42+
# See the documentation for each attribute for more details.
43+
#
44+
def initialize(username_arg = nil, oauth2_token_arg = nil,
45+
username: nil, authcid: nil, oauth2_token: nil, **)
46+
@authcid = authcid || username || username_arg
47+
@oauth2_token = oauth2_token || oauth2_token_arg
48+
@authcid or raise ArgumentError, "missing authcid (username)"
49+
@oauth2_token or raise ArgumentError, "missing oauth2_token"
50+
[authcid, username, username_arg].compact.count == 1 or
51+
raise ArgumentError, "conflicting values for authcid (username)"
52+
[oauth2_token, oauth2_token_arg].compact.count == 1 or
53+
raise ArgumentError, "conflicting values for oauth2_token"
54+
end
55+
56+
# :call-seq:
57+
# initial_response? -> true
58+
#
59+
# +PLAIN+ can send an initial client response.
560
def initial_response?; true end
661

7-
def process(_data)
8-
build_oauth2_string(@user, @oauth2_token)
9-
end
62+
##
63+
# method: authcid
64+
# :call-seq: authcid -> string
65+
#
66+
# Authentication identity: the identity that matches the #oauth2_token.
67+
#
68+
# RFC-2831[https://tools.ietf.org/html/rfc2831] uses the term +username+.
69+
# "Authentication identity" is the generic term used by
70+
# RFC-4422[https://tools.ietf.org/html/rfc4422].
71+
# RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs abbreviate
72+
# to +authcid+. #username is available as an alias for #authcid, but only
73+
# <tt>:authcid</tt> will be sent to callbacks.
74+
attr_reader :authcid
75+
alias username authcid
1076

11-
private
77+
##
78+
# method: oauth2_token
79+
# :call-seq: oauth2_token -> string
80+
#
81+
# An OAuth2 access token for #authcid, which has been authorized with
82+
# appropriate OAuth2 scopes to access IMAP.
83+
attr_reader :oauth2_token
1284

13-
def initialize(user, oauth2_token)
14-
@user = user
15-
@oauth2_token = oauth2_token
85+
# Returns the XOAUTH2 formatted response, which combines the +authcid+
86+
# with the +oauth2_token+.
87+
#
88+
# [Note]
89+
# For simplicity, the same response is always generated, even when the
90+
# server sends error data in its challenge. However, the \SASL
91+
# specification requires an empty response to acknowledge the error. This
92+
# has little effect in practice, any response to the error results in an
93+
# tagged error response: generally +NO+, but potentially +BAD+.
94+
def process(data)
95+
@last_server_response = data
96+
"user=%s\1auth=Bearer %s\1\1" % [username, oauth2_token]
1697
end
1798

18-
def build_oauth2_string(user, oauth2_token)
19-
format("user=%s\1auth=Bearer %s\1\1", user, oauth2_token)
20-
end
99+
# Stores the most recent server "challenge". When authentication fails, this
100+
# may hold information about the failure reason, as JSON.
101+
attr_reader :last_server_response
21102

22103
end

test/net/imap/test_imap_authenticators.rb

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,28 @@ def test_xoauth2_authenticator_matches_mechanism
176176
assert_kind_of(Net::IMAP::SASL::XOAuth2Authenticator, xoauth2("user", "tok"))
177177
end
178178

179-
def test_xoauth2
179+
def test_xoauth2_response
180180
assert_equal(
181181
"user=username\1auth=Bearer token\1\1",
182182
xoauth2("username", "token").process(nil)
183183
)
184184
end
185185

186+
def test_xoauth2_kwargs
187+
assert_equal(
188+
"user=arg\1auth=Bearer kwarg\1\1",
189+
xoauth2("arg", oauth2_token: "kwarg").process(nil)
190+
)
191+
assert_equal(
192+
"user=authc\1auth=Bearer kwarg\1\1",
193+
xoauth2(authcid: "authc", oauth2_token: "kwarg").process(nil)
194+
)
195+
assert_equal(
196+
"user=user\1auth=Bearer kwarg\1\1",
197+
xoauth2(username: "user", oauth2_token: "kwarg").process(nil)
198+
)
199+
end
200+
186201
def test_xoauth2_supports_initial_response
187202
assert xoauth2("foo", "bar").initial_response?
188203
assert Net::IMAP::SASL.initial_response?(xoauth2("foo", "bar"))

0 commit comments

Comments
 (0)