|
1 | 1 | # frozen_string_literal: true
|
2 | 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], |
| 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. |
3 | 16 | class Net::IMAP::SASL::XOAuth2Authenticator
|
4 | 17 |
|
| 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. |
5 | 60 | def initial_response?; true end
|
6 | 61 |
|
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 |
10 | 76 |
|
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 |
12 | 84 |
|
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] |
16 | 97 | end
|
17 | 98 |
|
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 |
21 | 102 |
|
22 | 103 | end
|
0 commit comments