Skip to content

Commit 7a2dc61

Browse files
committed
♻️ SASL XOAUTH2: inherit, move, test, document
* ♻️ Inherit from Authenticator base class * 🚚 Move to SASL module and rename file * ✅ Add new tests * 📖 Add many many docs * ✨ Add `initial_response? => true`
1 parent 2199b6c commit 7a2dc61

File tree

5 files changed

+152
-28
lines changed

5 files changed

+152
-28
lines changed

lib/net/imap/authenticators/xoauth2.rb

Lines changed: 0 additions & 22 deletions
This file was deleted.

lib/net/imap/sasl.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class IMAP
3333
# +PLAIN+:: See PlainAuthenticator.
3434
# Login using clear-text username and password.
3535
#
36-
# +XOAUTH2+:: See XOauth2Authenticator.
36+
# +XOAUTH2+:: See XOAuth2Authenticator.
3737
# Login using a username and OAuth2 access token.
3838
# Non-standard and obsoleted by +OAUTHBEARER+, but widely
3939
# supported.
@@ -63,12 +63,13 @@ module SASL
6363
autoload :Authenticator, "#{sasl_dir}/authenticator"
6464
autoload :Authenticators, "#{sasl_dir}/authenticators"
6565
autoload :PlainAuthenticator, "#{sasl_dir}/plain_authenticator"
66+
autoload :XOAuth2Authenticator, "#{sasl_dir}/xoauth2_authenticator"
6667

6768
# Authenticators are all lazy loaded
6869
def self.authenticators
6970
@authenticators ||= SASL::Authenticators.new.tap do |registry|
7071
registry.add_authenticator "Plain"
71-
registry.add_authenticator "XOauth2", XOauth2Authenticator
72+
registry.add_authenticator "XOAuth2"
7273
registry.add_authenticator "Login", LoginAuthenticator # deprecated
7374
registry.add_authenticator "Cram-MD5", CramMD5Authenticator # deprecated
7475
registry.add_authenticator "Digest-MD5", DigestMD5Authenticator # deprecated
@@ -109,4 +110,3 @@ def initial_response?(mechanism)
109110
require_relative "authenticators/login"
110111
require_relative "authenticators/cram_md5"
111112
require_relative "authenticators/digest_md5"
112-
require_relative "authenticators/xoauth2"

lib/net/imap/sasl/authenticators.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def authenticators; @authenticators.dup end
2828
# The authenticator session must respond to +#process+, receiving the server's
2929
# challenge and returning the client's response.
3030
#
31-
# See PlainAuthenticator, XOauth2Authenticator, and DigestMD5Authenticator for
31+
# See PlainAuthenticator, XOAuth2Authenticator, and DigestMD5Authenticator for
3232
# examples.
3333
def add_authenticator(mechanism, authenticator = nil, warn_overwrite: true)
3434
mechanism = mechanism.to_str
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "authenticator"
4+
5+
module Net
6+
class IMAP < Protocol
7+
module SASL
8+
9+
# Authenticator for the "+XOAUTH2+" SASL mechanism. This mechanism was
10+
# originally created for GMail and widely adopted by hosted email providers.
11+
# +XOAUTH2+ has been documented by
12+
# Google[https://developers.google.com/gmail/imap/xoauth2-protocol],
13+
# Microsoft[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth],
14+
# and Yahoo[https://senders.yahooinc.com/developer/documentation].
15+
#
16+
# This mechanism requires an OAuth2 +access_token+ which has been authorized
17+
# with the appropriate OAuth2 scopes to access IMAP. These scopes are not
18+
# standardized---consult each email service provider's documentation.
19+
#
20+
# Although this mechanism was never standardized and has been obsoleted by
21+
# "+OAUTHBEARER+", it is still very widely supported.
22+
class XOAuth2Authenticator < Authenticator
23+
24+
##
25+
# :call-seq:
26+
# new(username, oauth2_token, **) -> auth_ctx
27+
# new(authcid:, oauth2_token:, **) -> auth_ctx
28+
# new(**) {|propname, auth_ctx| propval } -> auth_ctx
29+
#
30+
# Creates an Authenticator for the "+XOAUTH2+" SASL mechanism, as
31+
# specified by
32+
# Google[https://developers.google.com/gmail/imap/xoauth2-protocol], and
33+
# also documented by
34+
# Microsoft[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth],
35+
# and Yahoo[https://senders.yahooinc.com/developer/documentation]. To use
36+
# this, see Net::IMAP#authenticate or your client's authentication
37+
# method.
38+
#
39+
# === Properties
40+
#
41+
# Both properties may be sent either as positional arguments or as
42+
# keyword arguments. If the same property is sent as both a positional
43+
# arg and a keyword arg, an ArgumentError is raised.
44+
#
45+
# * #authcid, #username --- the identity whose #password is used.
46+
# Only <tt>:authcid</tt> will be sent to callbacks.
47+
# * #oauth2_token --- An OAuth2.0 access token assigned to #username,
48+
# authorized to access IMAP.
49+
#
50+
# See the documentation for each property method for more details.
51+
#
52+
# See Net::IMAP::SASL::Authenticator@Properties for a detailed description
53+
# of property assignment, lazy loading, and callbacks.
54+
#
55+
def initialize(username_arg = nil, oauth2_token_arg = nil,
56+
username: nil, authcid: nil, oauth2_token: nil,
57+
**, &callback)
58+
super
59+
propinit :authcid, authcid, username, username_arg, required: true
60+
propinit :oauth2_token, oauth2_token, oauth2_token_arg, required: true
61+
end
62+
63+
# :call-seq:
64+
# initial_response? -> true
65+
#
66+
# +PLAIN+ can send an initial client response.
67+
def initial_response?; true end
68+
69+
##
70+
# method: authcid
71+
# :call-seq: authcid -> string
72+
#
73+
# Authentication identity: the identity that matches the #oauth2_token.
74+
#
75+
# RFC-2831[https://tools.ietf.org/html/rfc2831] uses the term +username+.
76+
# "Authentication identity" is the generic term used by
77+
# RFC-4422[https://tools.ietf.org/html/rfc4422].
78+
# RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs
79+
# abbreviate to +authcid+. #username is available as an alias for
80+
# #authcid, but only <tt>:authcid</tt> will be sent to callbacks.
81+
property :authcid
82+
alias username authcid
83+
84+
##
85+
# method: oauth2_token
86+
# :call-seq: oauth2_token -> string
87+
#
88+
# An OAuth2 access token for #authcid, which has been authorized with
89+
# appropriate OAuth2 scopes to access IMAP.
90+
property :oauth2_token
91+
92+
# Returns the XOAUTH2 formatted response, which combines the +authcid+
93+
# with the +oauth2_token+.
94+
#
95+
# [Note]
96+
# For simplicity, the same response is always generated, even when the
97+
# server sends error data in its challenge. However, the \SASL
98+
# specification requires an empty response to acknowledge the error.
99+
# This has little effect in practice, any response to the error results
100+
# in an tagged error response: generally +NO+, but potentially +BAD+.
101+
def process(data)
102+
@last_server_response = data
103+
"user=%s\1auth=Bearer %s\1\1" % [username, oauth2_token]
104+
end
105+
106+
# Stores the most recent server "challenge". When authentication fails,
107+
# this may hold information about the failure reason, as JSON.
108+
attr_reader :last_server_response
109+
110+
end
111+
end
112+
113+
XOauth2Authenticator = SASL::XOAuth2Authenticator # :nodoc:
114+
deprecate_constant :XOauth2Authenticator
115+
116+
end
117+
end

test/net/imap/test_imap_authenticators.rb

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,45 @@ def test_plain_no_null_chars
4747
def xoauth2(...) Net::IMAP::SASL.authenticator("XOAUTH2", ...) end
4848

4949
def test_xoauth2_authenticator_matches_mechanism
50-
assert_kind_of(Net::IMAP::XOauth2Authenticator, xoauth2("user", "pass"))
50+
assert_kind_of(Net::IMAP::SASL::XOAuth2Authenticator, xoauth2("user", "tok"))
5151
end
5252

53-
def test_xoauth2
53+
def test_xoauth2_response
5454
assert_equal(
5555
"user=username\1auth=Bearer token\1\1",
5656
xoauth2("username", "token").process(nil)
5757
)
5858
end
5959

60+
def test_xoauth2_kwargs
61+
assert_equal(
62+
"user=arg\1auth=Bearer kwarg\1\1",
63+
xoauth2("arg", oauth2_token: "kwarg").process(nil)
64+
)
65+
assert_equal(
66+
"user=authc\1auth=Bearer kwarg\1\1",
67+
xoauth2(authcid: "authc", oauth2_token: "kwarg").process(nil)
68+
)
69+
assert_equal(
70+
"user=user\1auth=Bearer kwarg\1\1",
71+
xoauth2(username: "user", oauth2_token: "kwarg").process(nil)
72+
)
73+
end
74+
75+
def test_xoauth2_callbacks
76+
assert_equal(
77+
"user=authc ID\1auth=Bearer lazy\1\1",
78+
xoauth2(authcid: "authc ID", oauth2_token: proc{ "lazy" }).process(nil)
79+
)
80+
assert_equal(
81+
"user=u\1auth=Bearer t\1\1",
82+
xoauth2 {|prop, authctx|
83+
assert_kind_of(Net::IMAP::SASL::XOAuth2Authenticator, authctx)
84+
case prop when :authcid then "u" when :oauth2_token then "t" end
85+
}.process(nil)
86+
)
87+
end
88+
6089
def test_xoauth2_supports_initial_response
6190
assert xoauth2("foo", "bar").initial_response?
6291
assert Net::IMAP::SASL.initial_response?(xoauth2("foo", "bar"))

0 commit comments

Comments
 (0)