net-imap 0.3.7 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/BSDL +22 -0
- data/COPYING +56 -0
- data/Gemfile +14 -0
- data/LICENSE.txt +3 -22
- data/README.md +25 -8
- data/Rakefile +0 -7
- data/docs/styles.css +72 -23
- data/lib/net/imap/authenticators.rb +26 -57
- data/lib/net/imap/command_data.rb +74 -54
- data/lib/net/imap/config/attr_accessors.rb +75 -0
- data/lib/net/imap/config/attr_inheritance.rb +90 -0
- data/lib/net/imap/config/attr_type_coercion.rb +61 -0
- data/lib/net/imap/config.rb +470 -0
- data/lib/net/imap/data_encoding.rb +18 -6
- data/lib/net/imap/data_lite.rb +226 -0
- data/lib/net/imap/deprecated_client_options.rb +142 -0
- data/lib/net/imap/errors.rb +27 -1
- data/lib/net/imap/esearch_result.rb +180 -0
- data/lib/net/imap/fetch_data.rb +597 -0
- data/lib/net/imap/flags.rb +1 -1
- data/lib/net/imap/response_data.rb +250 -440
- data/lib/net/imap/response_parser/parser_utils.rb +245 -0
- data/lib/net/imap/response_parser.rb +1867 -1184
- data/lib/net/imap/sasl/anonymous_authenticator.rb +69 -0
- data/lib/net/imap/sasl/authentication_exchange.rb +139 -0
- data/lib/net/imap/sasl/authenticators.rb +122 -0
- data/lib/net/imap/sasl/client_adapter.rb +123 -0
- data/lib/net/imap/{authenticators/cram_md5.rb → sasl/cram_md5_authenticator.rb} +24 -14
- data/lib/net/imap/sasl/digest_md5_authenticator.rb +342 -0
- data/lib/net/imap/sasl/external_authenticator.rb +83 -0
- data/lib/net/imap/sasl/gs2_header.rb +80 -0
- data/lib/net/imap/{authenticators/login.rb → sasl/login_authenticator.rb} +28 -18
- data/lib/net/imap/sasl/oauthbearer_authenticator.rb +199 -0
- data/lib/net/imap/sasl/plain_authenticator.rb +101 -0
- data/lib/net/imap/sasl/protocol_adapters.rb +101 -0
- data/lib/net/imap/sasl/scram_algorithm.rb +58 -0
- data/lib/net/imap/sasl/scram_authenticator.rb +287 -0
- data/lib/net/imap/sasl/stringprep.rb +6 -66
- data/lib/net/imap/sasl/xoauth2_authenticator.rb +106 -0
- data/lib/net/imap/sasl.rb +148 -44
- data/lib/net/imap/sasl_adapter.rb +20 -0
- data/lib/net/imap/search_result.rb +146 -0
- data/lib/net/imap/sequence_set.rb +1565 -0
- data/lib/net/imap/stringprep/nameprep.rb +70 -0
- data/lib/net/imap/stringprep/saslprep.rb +69 -0
- data/lib/net/imap/stringprep/saslprep_tables.rb +96 -0
- data/lib/net/imap/stringprep/tables.rb +146 -0
- data/lib/net/imap/stringprep/trace.rb +85 -0
- data/lib/net/imap/stringprep.rb +159 -0
- data/lib/net/imap/uidplus_data.rb +244 -0
- data/lib/net/imap/vanished_data.rb +56 -0
- data/lib/net/imap.rb +2090 -823
- data/net-imap.gemspec +7 -8
- data/rakelib/benchmarks.rake +91 -0
- data/rakelib/rfcs.rake +2 -0
- data/rakelib/saslprep.rake +4 -4
- data/rakelib/string_prep_tables_generator.rb +84 -60
- data/sample/net-imap.rb +167 -0
- metadata +45 -49
- data/.github/dependabot.yml +0 -6
- data/.github/workflows/test.yml +0 -38
- data/.gitignore +0 -10
- data/benchmarks/stringprep.yml +0 -65
- data/benchmarks/table-regexps.yml +0 -39
- data/lib/net/imap/authenticators/digest_md5.rb +0 -115
- data/lib/net/imap/authenticators/plain.rb +0 -41
- data/lib/net/imap/authenticators/xoauth2.rb +0 -20
- data/lib/net/imap/sasl/saslprep.rb +0 -55
- data/lib/net/imap/sasl/saslprep_tables.rb +0 -98
- data/lib/net/imap/sasl/stringprep_tables.rb +0 -153
@@ -0,0 +1,287 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
require "securerandom"
|
5
|
+
|
6
|
+
require_relative "gs2_header"
|
7
|
+
require_relative "scram_algorithm"
|
8
|
+
|
9
|
+
module Net
|
10
|
+
class IMAP
|
11
|
+
module SASL
|
12
|
+
|
13
|
+
# Abstract base class for the "+SCRAM-*+" family of SASL mechanisms,
|
14
|
+
# defined in RFC5802[https://www.rfc-editor.org/rfc/rfc5802]. Use via
|
15
|
+
# Net::IMAP#authenticate.
|
16
|
+
#
|
17
|
+
# Directly supported:
|
18
|
+
# * +SCRAM-SHA-1+ --- ScramSHA1Authenticator
|
19
|
+
# * +SCRAM-SHA-256+ --- ScramSHA256Authenticator
|
20
|
+
#
|
21
|
+
# New +SCRAM-*+ mechanisms can easily be added for any hash algorithm
|
22
|
+
# supported by
|
23
|
+
# OpenSSL::Digest[https://ruby.github.io/openssl/OpenSSL/Digest.html].
|
24
|
+
# Subclasses need only set an appropriate +DIGEST_NAME+ constant.
|
25
|
+
#
|
26
|
+
# === SCRAM algorithm
|
27
|
+
#
|
28
|
+
# See the documentation and method definitions on ScramAlgorithm for an
|
29
|
+
# overview of the algorithm. The different mechanisms differ only by
|
30
|
+
# which hash function that is used (or by support for channel binding with
|
31
|
+
# +-PLUS+).
|
32
|
+
#
|
33
|
+
# See also the methods on GS2Header.
|
34
|
+
#
|
35
|
+
# ==== Server messages
|
36
|
+
#
|
37
|
+
# As server messages are received, they are validated and loaded into
|
38
|
+
# the various attributes, e.g: #snonce, #salt, #iterations, #verifier,
|
39
|
+
# #server_error, etc.
|
40
|
+
#
|
41
|
+
# Unlike many other SASL mechanisms, the +SCRAM-*+ family supports mutual
|
42
|
+
# authentication and can return server error data in the server messages.
|
43
|
+
# If #process raises an Error for the server-final-message, then
|
44
|
+
# server_error may contain error details.
|
45
|
+
#
|
46
|
+
# === TLS Channel binding
|
47
|
+
#
|
48
|
+
# <em>The <tt>SCRAM-*-PLUS</tt> mechanisms and channel binding are not
|
49
|
+
# supported yet.</em>
|
50
|
+
#
|
51
|
+
# === Caching SCRAM secrets
|
52
|
+
#
|
53
|
+
# <em>Caching of salted_password, client_key, stored_key, and server_key
|
54
|
+
# is not supported yet.</em>
|
55
|
+
#
|
56
|
+
class ScramAuthenticator
|
57
|
+
include GS2Header
|
58
|
+
include ScramAlgorithm
|
59
|
+
|
60
|
+
# :call-seq:
|
61
|
+
# new(username, password, **options) -> auth_ctx
|
62
|
+
# new(username:, password:, **options) -> auth_ctx
|
63
|
+
# new(authcid:, password:, **options) -> auth_ctx
|
64
|
+
#
|
65
|
+
# Creates an authenticator for one of the "+SCRAM-*+" SASL mechanisms.
|
66
|
+
# Each subclass defines #digest to match a specific mechanism.
|
67
|
+
#
|
68
|
+
# Called by Net::IMAP#authenticate and similar methods on other clients.
|
69
|
+
#
|
70
|
+
# === Parameters
|
71
|
+
#
|
72
|
+
# * #authcid ― Identity whose #password is used.
|
73
|
+
#
|
74
|
+
# #username - An alias for #authcid.
|
75
|
+
# * #password ― Password or passphrase associated with this #username.
|
76
|
+
# * _optional_ #authzid ― Alternate identity to act as or on behalf of.
|
77
|
+
# * _optional_ #min_iterations - Overrides the default value (4096).
|
78
|
+
#
|
79
|
+
# Any other keyword parameters are quietly ignored.
|
80
|
+
def initialize(username_arg = nil, password_arg = nil,
|
81
|
+
authcid: nil, username: nil,
|
82
|
+
authzid: nil,
|
83
|
+
password: nil, secret: nil,
|
84
|
+
min_iterations: 4096, # see both RFC5802 and RFC7677
|
85
|
+
cnonce: nil, # must only be set in tests
|
86
|
+
**options)
|
87
|
+
@username = username || username_arg || authcid or
|
88
|
+
raise ArgumentError, "missing username (authcid)"
|
89
|
+
@password = password || secret || password_arg or
|
90
|
+
raise ArgumentError, "missing password"
|
91
|
+
@authzid = authzid
|
92
|
+
|
93
|
+
@min_iterations = Integer min_iterations
|
94
|
+
@min_iterations.positive? or
|
95
|
+
raise ArgumentError, "min_iterations must be positive"
|
96
|
+
|
97
|
+
@cnonce = cnonce || SecureRandom.base64(32)
|
98
|
+
end
|
99
|
+
|
100
|
+
# Authentication identity: the identity that matches the #password.
|
101
|
+
#
|
102
|
+
# RFC-2831[https://www.rfc-editor.org/rfc/rfc2831] uses the term
|
103
|
+
# +username+. "Authentication identity" is the generic term used by
|
104
|
+
# RFC-4422[https://www.rfc-editor.org/rfc/rfc4422].
|
105
|
+
# RFC-4616[https://www.rfc-editor.org/rfc/rfc4616] and many later RFCs
|
106
|
+
# abbreviate this to +authcid+.
|
107
|
+
attr_reader :username
|
108
|
+
alias authcid username
|
109
|
+
|
110
|
+
# A password or passphrase that matches the #username.
|
111
|
+
attr_reader :password
|
112
|
+
alias secret password
|
113
|
+
|
114
|
+
# Authorization identity: an identity to act as or on behalf of. The
|
115
|
+
# identity form is application protocol specific. If not provided or
|
116
|
+
# left blank, the server derives an authorization identity from the
|
117
|
+
# authentication identity. For example, an administrator or superuser
|
118
|
+
# might take on another role:
|
119
|
+
#
|
120
|
+
# imap.authenticate "SCRAM-SHA-256", "root", passwd, authzid: "user"
|
121
|
+
#
|
122
|
+
# The server is responsible for verifying the client's credentials and
|
123
|
+
# verifying that the identity it associates with the client's
|
124
|
+
# authentication identity is allowed to act as (or on behalf of) the
|
125
|
+
# authorization identity.
|
126
|
+
attr_reader :authzid
|
127
|
+
|
128
|
+
# The minimal allowed iteration count. Lower #iterations will raise an
|
129
|
+
# Error.
|
130
|
+
attr_reader :min_iterations
|
131
|
+
|
132
|
+
# The client nonce, generated by SecureRandom
|
133
|
+
attr_reader :cnonce
|
134
|
+
|
135
|
+
# The server nonce, which must start with #cnonce
|
136
|
+
attr_reader :snonce
|
137
|
+
|
138
|
+
# The salt used by the server for this user
|
139
|
+
attr_reader :salt
|
140
|
+
|
141
|
+
# The iteration count for the selected hash function and user
|
142
|
+
attr_reader :iterations
|
143
|
+
|
144
|
+
# An error reported by the server during the \SASL exchange.
|
145
|
+
#
|
146
|
+
# Does not include errors reported by the protocol, e.g.
|
147
|
+
# Net::IMAP::NoResponseError.
|
148
|
+
attr_reader :server_error
|
149
|
+
|
150
|
+
# Returns a new OpenSSL::Digest object, set to the appropriate hash
|
151
|
+
# function for the chosen mechanism.
|
152
|
+
#
|
153
|
+
# <em>The class's +DIGEST_NAME+ constant must be set to the name of an
|
154
|
+
# algorithm supported by OpenSSL::Digest.</em>
|
155
|
+
def digest; OpenSSL::Digest.new self.class::DIGEST_NAME end
|
156
|
+
|
157
|
+
# See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
|
158
|
+
# +client-first-message+.
|
159
|
+
def initial_client_response
|
160
|
+
"#{gs2_header}#{client_first_message_bare}"
|
161
|
+
end
|
162
|
+
|
163
|
+
# responds to the server's challenges
|
164
|
+
def process(challenge)
|
165
|
+
case (@state ||= :initial_client_response)
|
166
|
+
when :initial_client_response
|
167
|
+
initial_client_response.tap { @state = :server_first_message }
|
168
|
+
when :server_first_message
|
169
|
+
recv_server_first_message challenge
|
170
|
+
final_message_with_proof.tap { @state = :server_final_message }
|
171
|
+
when :server_final_message
|
172
|
+
recv_server_final_message challenge
|
173
|
+
"".tap { @state = :done }
|
174
|
+
else
|
175
|
+
raise Error, "server sent after complete, %p" % [challenge]
|
176
|
+
end
|
177
|
+
rescue Exception => ex
|
178
|
+
@state = ex
|
179
|
+
raise
|
180
|
+
end
|
181
|
+
|
182
|
+
# Is the authentication exchange complete?
|
183
|
+
#
|
184
|
+
# If false, another server continuation is required.
|
185
|
+
def done?; @state == :done end
|
186
|
+
|
187
|
+
private
|
188
|
+
|
189
|
+
# Need to store this for auth_message
|
190
|
+
attr_reader :server_first_message
|
191
|
+
|
192
|
+
def format_message(hash) hash.map { _1.join("=") }.join(",") end
|
193
|
+
|
194
|
+
def recv_server_first_message(server_first_message)
|
195
|
+
@server_first_message = server_first_message
|
196
|
+
sparams = parse_challenge server_first_message
|
197
|
+
@snonce = sparams["r"] or
|
198
|
+
raise Error, "server did not send nonce"
|
199
|
+
@salt = sparams["s"]&.unpack1("m") or
|
200
|
+
raise Error, "server did not send salt"
|
201
|
+
@iterations = sparams["i"]&.then {|i| Integer i } or
|
202
|
+
raise Error, "server did not send iteration count"
|
203
|
+
min_iterations <= iterations or
|
204
|
+
raise Error, "too few iterations: %d" % [iterations]
|
205
|
+
mext = sparams["m"] and
|
206
|
+
raise Error, "mandatory extension: %p" % [mext]
|
207
|
+
snonce.start_with? cnonce or
|
208
|
+
raise Error, "invalid server nonce"
|
209
|
+
end
|
210
|
+
|
211
|
+
def recv_server_final_message(server_final_message)
|
212
|
+
sparams = parse_challenge server_final_message
|
213
|
+
@server_error = sparams["e"] and
|
214
|
+
raise Error, "server error: %s" % [server_error]
|
215
|
+
verifier = sparams["v"].unpack1("m") or
|
216
|
+
raise Error, "server did not send verifier"
|
217
|
+
verifier == server_signature or
|
218
|
+
raise Error, "server verify failed: %p != %p" % [
|
219
|
+
server_signature, verifier
|
220
|
+
]
|
221
|
+
end
|
222
|
+
|
223
|
+
# See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
|
224
|
+
# +client-first-message-bare+.
|
225
|
+
def client_first_message_bare
|
226
|
+
@client_first_message_bare ||=
|
227
|
+
format_message(n: gs2_saslname_encode(SASL.saslprep(username)),
|
228
|
+
r: cnonce)
|
229
|
+
end
|
230
|
+
|
231
|
+
# See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
|
232
|
+
# +client-final-message+.
|
233
|
+
def final_message_with_proof
|
234
|
+
proof = [client_proof].pack("m0")
|
235
|
+
"#{client_final_message_without_proof},p=#{proof}"
|
236
|
+
end
|
237
|
+
|
238
|
+
# See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
|
239
|
+
# +client-final-message-without-proof+.
|
240
|
+
def client_final_message_without_proof
|
241
|
+
@client_final_message_without_proof ||=
|
242
|
+
format_message(c: [cbind_input].pack("m0"), # channel-binding
|
243
|
+
r: snonce) # nonce
|
244
|
+
end
|
245
|
+
|
246
|
+
# See {RFC5802 §7}[https://www.rfc-editor.org/rfc/rfc5802#section-7]
|
247
|
+
# +cbind-input+.
|
248
|
+
#
|
249
|
+
# >>>
|
250
|
+
# *TODO:* implement channel binding, appending +cbind-data+ here.
|
251
|
+
alias cbind_input gs2_header
|
252
|
+
|
253
|
+
# RFC5802 specifies "that the order of attributes in client or server
|
254
|
+
# messages is fixed, with the exception of extension attributes", but
|
255
|
+
# this parses it simply as a hash, without respect to order. Note that
|
256
|
+
# repeated keys (violating the spec) will use the last value.
|
257
|
+
def parse_challenge(challenge)
|
258
|
+
challenge.split(/,/).to_h {|pair| pair.split(/=/, 2) }
|
259
|
+
rescue ArgumentError
|
260
|
+
raise Error, "unparsable challenge: %p" % [challenge]
|
261
|
+
end
|
262
|
+
|
263
|
+
end
|
264
|
+
|
265
|
+
# Authenticator for the "+SCRAM-SHA-1+" SASL mechanism, defined in
|
266
|
+
# RFC5802[https://www.rfc-editor.org/rfc/rfc5802].
|
267
|
+
#
|
268
|
+
# Uses the "SHA-1" digest algorithm from OpenSSL::Digest.
|
269
|
+
#
|
270
|
+
# See ScramAuthenticator.
|
271
|
+
class ScramSHA1Authenticator < ScramAuthenticator
|
272
|
+
DIGEST_NAME = "SHA1"
|
273
|
+
end
|
274
|
+
|
275
|
+
# Authenticator for the "+SCRAM-SHA-256+" SASL mechanism, defined in
|
276
|
+
# RFC7677[https://www.rfc-editor.org/rfc/rfc7677].
|
277
|
+
#
|
278
|
+
# Uses the "SHA-256" digest algorithm from OpenSSL::Digest.
|
279
|
+
#
|
280
|
+
# See ScramAuthenticator.
|
281
|
+
class ScramSHA256Authenticator < ScramAuthenticator
|
282
|
+
DIGEST_NAME = "SHA256"
|
283
|
+
end
|
284
|
+
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
@@ -1,72 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative "stringprep_tables"
|
4
|
-
|
5
3
|
module Net::IMAP::SASL
|
6
4
|
|
7
|
-
#
|
8
|
-
|
9
|
-
#
|
10
|
-
#
|
11
|
-
|
12
|
-
|
13
|
-
# TODO: generic StringPrep mapping (not needed for SASLprep implementation)
|
14
|
-
#++
|
15
|
-
module StringPrep
|
16
|
-
|
17
|
-
# Returns a Regexp matching the given +table+ name.
|
18
|
-
def self.[](table)
|
19
|
-
TABLE_REGEXPS.fetch(table)
|
20
|
-
end
|
21
|
-
|
22
|
-
module_function
|
23
|
-
|
24
|
-
# Checks +string+ for any codepoint in +tables+. Raises a
|
25
|
-
# ProhibitedCodepoint describing the first matching table.
|
26
|
-
#
|
27
|
-
# Also checks bidirectional characters, when <tt>bidi: true</tt>, which may
|
28
|
-
# raise a BidiStringError.
|
29
|
-
#
|
30
|
-
# +profile+ is an optional string which will be added to any exception that
|
31
|
-
# is raised (it does not affect behavior).
|
32
|
-
def check_prohibited!(string, *tables, bidi: false, profile: nil)
|
33
|
-
tables = TABLE_TITLES.keys.grep(/^C/) if tables.empty?
|
34
|
-
tables |= %w[C.8] if bidi
|
35
|
-
table = tables.find {|t| TABLE_REGEXPS[t].match?(string) }
|
36
|
-
raise ProhibitedCodepoint.new(
|
37
|
-
table, string: string, profile: nil
|
38
|
-
) if table
|
39
|
-
check_bidi!(string, profile: profile) if bidi
|
40
|
-
end
|
41
|
-
|
42
|
-
# Checks that +string+ obeys all of the "Bidirectional Characters"
|
43
|
-
# requirements in RFC-3454, §6:
|
44
|
-
#
|
45
|
-
# * The characters in \StringPrep\[\"C.8\"] MUST be prohibited
|
46
|
-
# * If a string contains any RandALCat character, the string MUST NOT
|
47
|
-
# contain any LCat character.
|
48
|
-
# * If a string contains any RandALCat character, a RandALCat
|
49
|
-
# character MUST be the first character of the string, and a
|
50
|
-
# RandALCat character MUST be the last character of the string.
|
51
|
-
#
|
52
|
-
# This is usually combined with #check_prohibited!, so table "C.8" is only
|
53
|
-
# checked when <tt>c_8: true</tt>.
|
54
|
-
#
|
55
|
-
# Raises either ProhibitedCodepoint or BidiStringError unless all
|
56
|
-
# requirements are met. +profile+ is an optional string which will be
|
57
|
-
# added to any exception that is raised (it does not affect behavior).
|
58
|
-
def check_bidi!(string, c_8: false, profile: nil)
|
59
|
-
check_prohibited!(string, "C.8", profile: profile) if c_8
|
60
|
-
if BIDI_FAILS_REQ2.match?(string)
|
61
|
-
raise BidiStringError.new(
|
62
|
-
BIDI_DESC_REQ2, string: string, profile: profile,
|
63
|
-
)
|
64
|
-
elsif BIDI_FAILS_REQ3.match?(string)
|
65
|
-
raise BidiStringError.new(
|
66
|
-
BIDI_DESC_REQ3, string: string, profile: profile,
|
67
|
-
)
|
68
|
-
end
|
69
|
-
end
|
5
|
+
# Alias for Net::IMAP::StringPrep::SASLprep.
|
6
|
+
SASLprep = Net::IMAP::StringPrep::SASLprep
|
7
|
+
StringPrep = Net::IMAP::StringPrep # :nodoc:
|
8
|
+
BidiStringError = Net::IMAP::StringPrep::BidiStringError # :nodoc:
|
9
|
+
ProhibitedCodepoint = Net::IMAP::StringPrep::ProhibitedCodepoint # :nodoc:
|
10
|
+
StringPrepError = Net::IMAP::StringPrep::StringPrepError # :nodoc:
|
70
11
|
|
71
|
-
end
|
72
12
|
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
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] and
|
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
|
+
#
|
9
|
+
# This mechanism requires an OAuth2 access token which has been authorized
|
10
|
+
# with the appropriate OAuth2 scopes to access the user's services. Most of
|
11
|
+
# these scopes are not standardized---consult each service provider's
|
12
|
+
# documentation for their scopes.
|
13
|
+
#
|
14
|
+
# Although this mechanism was never standardized and has been obsoleted by
|
15
|
+
# "+OAUTHBEARER+", it is still very widely supported.
|
16
|
+
#
|
17
|
+
# See Net::IMAP::SASL::OAuthBearerAuthenticator.
|
18
|
+
class Net::IMAP::SASL::XOAuth2Authenticator
|
19
|
+
|
20
|
+
# It is unclear from {Google's original XOAUTH2
|
21
|
+
# documentation}[https://developers.google.com/gmail/imap/xoauth2-protocol],
|
22
|
+
# whether "User" refers to the authentication identity (+authcid+) or the
|
23
|
+
# authorization identity (+authzid+). The authentication identity is
|
24
|
+
# established for the client by the OAuth token, so it seems that +username+
|
25
|
+
# must be the authorization identity.
|
26
|
+
#
|
27
|
+
# {Microsoft's documentation for shared
|
28
|
+
# mailboxes}[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth#sasl-xoauth2-authentication-for-shared-mailboxes-in-office-365]
|
29
|
+
# _clearly_ indicates that the Office 365 server interprets it as the
|
30
|
+
# authorization identity.
|
31
|
+
#
|
32
|
+
# Although they _should_ validate that the token has been authorized to access
|
33
|
+
# the service for +username+, _some_ servers appear to ignore this field,
|
34
|
+
# relying only the identity and scope authorized by the token.
|
35
|
+
attr_reader :username
|
36
|
+
|
37
|
+
# Note that, unlike most other authenticators, #username is an alias for the
|
38
|
+
# authorization identity and not the authentication identity. The
|
39
|
+
# authenticated identity is established for the client by the #oauth2_token.
|
40
|
+
alias authzid username
|
41
|
+
|
42
|
+
# An OAuth2 access token which has been authorized with the appropriate OAuth2
|
43
|
+
# scopes to use the service for #username.
|
44
|
+
attr_reader :oauth2_token
|
45
|
+
alias secret oauth2_token
|
46
|
+
|
47
|
+
# :call-seq:
|
48
|
+
# new(username, oauth2_token, **) -> authenticator
|
49
|
+
# new(username:, oauth2_token:, **) -> authenticator
|
50
|
+
# new(authzid:, oauth2_token:, **) -> authenticator
|
51
|
+
#
|
52
|
+
# Creates an Authenticator for the "+XOAUTH2+" SASL mechanism, as specified by
|
53
|
+
# Google[https://developers.google.com/gmail/imap/xoauth2-protocol],
|
54
|
+
# Microsoft[https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth]
|
55
|
+
# and Yahoo[https://senders.yahooinc.com/developer/documentation].
|
56
|
+
#
|
57
|
+
# === Properties
|
58
|
+
#
|
59
|
+
# * #username --- the username for the account being accessed.
|
60
|
+
#
|
61
|
+
# #authzid --- an alias for #username.
|
62
|
+
#
|
63
|
+
# Note that, unlike some other authenticators, +username+ sets the
|
64
|
+
# _authorization_ identity and not the _authentication_ identity. The
|
65
|
+
# authenticated identity is established for the client with the OAuth token.
|
66
|
+
#
|
67
|
+
# * #oauth2_token --- An OAuth2.0 access token which is authorized to access
|
68
|
+
# the service for #username.
|
69
|
+
#
|
70
|
+
# Any other keyword parameters are quietly ignored.
|
71
|
+
def initialize(user = nil, token = nil, username: nil, oauth2_token: nil,
|
72
|
+
authzid: nil, secret: nil, **)
|
73
|
+
@username = authzid || username || user or
|
74
|
+
raise ArgumentError, "missing username (authzid)"
|
75
|
+
@oauth2_token = oauth2_token || secret || token or
|
76
|
+
raise ArgumentError, "missing oauth2_token"
|
77
|
+
@done = false
|
78
|
+
end
|
79
|
+
|
80
|
+
# :call-seq:
|
81
|
+
# initial_response? -> true
|
82
|
+
#
|
83
|
+
# +XOAUTH2+ can send an initial client response.
|
84
|
+
def initial_response?; true end
|
85
|
+
|
86
|
+
# Returns the XOAUTH2 formatted response, which combines the +username+
|
87
|
+
# with the +oauth2_token+.
|
88
|
+
def process(_data)
|
89
|
+
build_oauth2_string(@username, @oauth2_token)
|
90
|
+
ensure
|
91
|
+
@done = true
|
92
|
+
end
|
93
|
+
|
94
|
+
# Returns true when the initial client response was sent.
|
95
|
+
#
|
96
|
+
# The authentication should not succeed unless this returns true, but it
|
97
|
+
# does *not* indicate success.
|
98
|
+
def done?; @done end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def build_oauth2_string(username, oauth2_token)
|
103
|
+
format("user=%s\1auth=Bearer %s\1\1", username, oauth2_token)
|
104
|
+
end
|
105
|
+
|
106
|
+
end
|