Skip to content

Commit 0ca5152

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 50d570b commit 0ca5152

File tree

5 files changed

+155
-25
lines changed

5 files changed

+155
-25
lines changed

lib/net/imap/authenticators/xoauth2.rb

Lines changed: 0 additions & 19 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
@@ -105,4 +106,3 @@ def saslprep(string, **opts)
105106
require_relative "authenticators/login"
106107
require_relative "authenticators/cram_md5"
107108
require_relative "authenticators/digest_md5"
108-
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: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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+
##
64+
# method: authcid
65+
# :call-seq: authcid -> string
66+
#
67+
# Authentication identity: the identity that matches the #oauth2_token.
68+
#
69+
# RFC-2831[https://tools.ietf.org/html/rfc2831] uses the term +username+.
70+
# "Authentication identity" is the generic term used by
71+
# RFC-4422[https://tools.ietf.org/html/rfc4422].
72+
# RFC-4616[https://tools.ietf.org/html/rfc4616] and many later RFCs
73+
# abbreviate to +authcid+. #username is available as an alias for
74+
# #authcid, but only <tt>:authcid</tt> will be sent to callbacks.
75+
property :authcid
76+
alias username authcid
77+
78+
##
79+
# method: oauth2_token
80+
# :call-seq: oauth2_token -> string
81+
#
82+
# An OAuth2 access token for #authcid, which has been authorized with
83+
# appropriate OAuth2 scopes to access IMAP.
84+
property :oauth2_token
85+
86+
# Returns the XOAUTH2 formatted response, which combines the +authcid+
87+
# with the +oauth2_token+.
88+
#
89+
# [Note]
90+
# For simplicity, the same response is always generated, even when the
91+
# server sends error data in its challenge. However, the \SASL
92+
# specification requires an empty response to acknowledge the error.
93+
# This has little effect in practice, any response to the error results
94+
# in an tagged error response: generally +NO+, but potentially +BAD+.
95+
def process(data)
96+
@last_server_response = data
97+
"user=%s\1auth=Bearer %s\1\1" % [username, oauth2_token]
98+
end
99+
100+
# Stores the most recent server "challenge". When authentication fails,
101+
# this may hold information about the failure reason, as JSON.
102+
attr_reader :last_server_response
103+
104+
end
105+
end
106+
107+
XOauth2Authenticator = SASL::XOAuth2Authenticator # :nodoc:
108+
deprecate_constant :XOauth2Authenticator
109+
110+
end
111+
end

test/net/imap/test_imap_authenticators.rb

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,48 @@ def test_plain_no_null_chars
4141
# XOAUTH2
4242
# ----------------------
4343

44-
def test_xoauth2
44+
def test_xoauth2_authenticator_matches_mechanism
45+
assert_kind_of(Net::IMAP::SASL::XOAuth2Authenticator,
46+
Net::IMAP.authenticator("XOAUTH2", "user", "tok"))
47+
end
48+
49+
def xoauth2(*args, **kwargs, &block)
50+
Net::IMAP.authenticator("XOAUTH2", *args, **kwargs, &block)
51+
end
52+
53+
def test_xoauth2_response
4554
assert_equal(
4655
"user=username\1auth=Bearer token\1\1",
47-
Net::IMAP::XOauth2Authenticator.new("username", "token").process(nil)
56+
xoauth2("username", "token").process(nil)
57+
)
58+
end
59+
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)
4886
)
4987
end
5088

0 commit comments

Comments
 (0)