summaryrefslogtreecommitdiff
path: root/contrib/oauth2.py
diff options
context:
space:
mode:
Diffstat (limited to 'contrib/oauth2.py')
-rwxr-xr-xcontrib/oauth2.py348
1 files changed, 348 insertions, 0 deletions
diff --git a/contrib/oauth2.py b/contrib/oauth2.py
new file mode 100755
index 00000000..a1ab65f2
--- /dev/null
+++ b/contrib/oauth2.py
@@ -0,0 +1,348 @@
+#!/usr/bin/env python3
+#
+# Copyright 2012 Google Inc.
+# Copyright 2020 Manos Pitsidianakis
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+ # http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Performs client tasks for testing IMAP OAuth2 authentication.
+
+To use this script, you'll need to have registered with Google as an OAuth
+application and obtained an OAuth client ID and client secret.
+See https://developers.google.com/identity/protocols/OAuth2 for instructions on
+registering and for documentation of the APIs invoked by this code.
+
+This script has 3 modes of operation.
+
+1. The first mode is used to generate and authorize an OAuth2 token, the
+first step in logging in via OAuth2.
+
+ oauth2 --user=xxx@gmail.com \
+ --client_id=1038[...].apps.googleusercontent.com \
+ --client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \
+ --generate_oauth2_token
+
+The script will converse with Google and generate an oauth request
+token, then present you with a URL you should visit in your browser to
+authorize the token. Once you get the verification code from the Google
+website, enter it into the script to get your OAuth access token. The output
+from this command will contain the access token, a refresh token, and some
+metadata about the tokens. The access token can be used until it expires, and
+the refresh token lasts indefinitely, so you should record these values for
+reuse.
+
+2. The script will generate new access tokens using a refresh token.
+
+ oauth2 --user=xxx@gmail.com \
+ --client_id=1038[...].apps.googleusercontent.com \
+ --client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \
+ --refresh_token=1/Yzm6MRy4q1xi7Dx2DuWXNgT6s37OrP_DW_IoyTum4YA
+
+3. The script will generate an OAuth2 string that can be fed
+directly to IMAP or SMTP. This is triggered with the --generate_oauth2_string
+option.
+
+ oauth2 --generate_oauth2_string --user=xxx@gmail.com \
+ --access_token=ya29.AGy[...]ezLg
+
+The output of this mode will be a base64-encoded string. To use it, connect to a
+IMAPFE and pass it as the second argument to the AUTHENTICATE command.
+
+ a AUTHENTICATE XOAUTH2 a9sha9sfs[...]9dfja929dk==
+"""
+
+import base64
+import imaplib
+import json
+from optparse import OptionParser
+import smtplib
+import sys
+import urllib.request, urllib.parse, urllib.error
+
+
+def SetupOptionParser():
+ # Usage message is the module's docstring.
+ parser = OptionParser(usage=__doc__)
+ parser.add_option('--generate_oauth2_token',
+ action='store_true',
+ dest='generate_oauth2_token',
+ help='generates an OAuth2 token for testing')
+ parser.add_option('--generate_oauth2_string',
+ action='store_true',
+ dest='generate_oauth2_string',
+ help='generates an initial client response string for '
+ 'OAuth2')
+ parser.add_option('--client_id',
+ default=None,
+ help='Client ID of the application that is authenticating. '
+ 'See OAuth2 documentation for details.')
+ parser.add_option('--client_secret',
+ default=None,
+ help='Client secret of the application that is '
+ 'authenticating. See OAuth2 documentation for '
+ 'details.')
+ parser.add_option('--access_token',
+ default=None,
+ help='OAuth2 access token')
+ parser.add_option('--refresh_token',
+ default=None,
+ help='OAuth2 refresh token')
+ parser.add_option('--scope',
+ default='https://mail.google.com/',
+ help='scope for the access token. Multiple scopes can be '
+ 'listed separated by spaces with the whole argument '
+ 'quoted.')
+ parser.add_option('--test_imap_authentication',
+ action='store_true',
+ dest='test_imap_authentication',
+ help='attempts to authenticate to IMAP')
+ parser.add_option('--test_smtp_authentication',
+ action='store_true',
+ dest='test_smtp_authentication',
+ help='attempts to authenticate to SMTP')
+ parser.add_option('--user',
+ default=None,
+ help='email address of user whose account is being '
+ 'accessed')
+ parser.add_option('--quiet',
+ action='store_true',
+ default=False,
+ dest='quiet',
+ help='Omit verbose descriptions and only print '
+ 'machine-readable outputs.')
+ return parser
+
+
+# The URL root for accessing Google Accounts.
+GOOGLE_ACCOUNTS_BASE_URL = 'https://accounts.google.com'
+
+
+# Hardcoded dummy redirect URI for non-web apps.
+REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
+
+
+def AccountsUrl(command):
+ """Generates the Google Accounts URL.
+
+ Args:
+ command: The command to execute.
+
+ Returns:
+ A URL for the given command.
+ """
+ return '%s/%s' % (GOOGLE_ACCOUNTS_BASE_URL, command)
+
+
+def UrlEscape(text):
+ # See OAUTH 5.1 for a definition of which characters need to be escaped.
+ return urllib.parse.quote(text, safe='~-._')
+
+
+def UrlUnescape(text):
+ # See OAUTH 5.1 for a definition of which characters need to be escaped.
+ return urllib.parse.unquote(text)
+
+
+def FormatUrlParams(params):
+ """Formats parameters into a URL query string.
+
+ Args:
+ params: A key-value map.
+
+ Returns:
+ A URL query string version of the given parameters.
+ """
+ param_fragments = []
+ for param in sorted(iter(params.items()), key=lambda x: x[0]):
+ param_fragments.append('%s=%s' % (param[0], UrlEscape(param[1])))
+ return '&'.join(param_fragments)
+
+
+def GeneratePermissionUrl(client_id, scope='https://mail.google.com/'):
+ """Generates the URL for authorizing access.
+
+ This uses the "OAuth2 for Installed Applications" flow described at
+ https://developers.google.com/accounts/docs/OAuth2InstalledApp
+
+ Args:
+ client_id: Client ID obtained by registering your app.
+ scope: scope for access token, e.g. 'https://mail.google.com'
+ Returns:
+ A URL that the user should visit in their browser.
+ """
+ params = {}
+ params['client_id'] = client_id
+ params['redirect_uri'] = REDIRECT_URI
+ params['scope'] = scope
+ params['response_type'] = 'code'
+ return '%s?%s' % (AccountsUrl('o/oauth2/auth'),
+ FormatUrlParams(params))
+
+
+def AuthorizeTokens(client_id, client_secret, authorization_code):
+ """Obtains OAuth access token and refresh token.
+
+ This uses the application portion of the "OAuth2 for Installed Applications"
+ flow at https://developers.google.com/accounts/docs/OAuth2InstalledApp#handlingtheresponse
+
+ Args:
+ client_id: Client ID obtained by registering your app.
+ client_secret: Client secret obtained by registering your app.
+ authorization_code: code generated by Google Accounts after user grants
+ permission.
+ Returns:
+ The decoded response from the Google Accounts server, as a dict. Expected
+ fields include 'access_token', 'expires_in', and 'refresh_token'.
+ """
+ params = {}
+ params['client_id'] = client_id
+ params['client_secret'] = client_secret
+ params['code'] = authorization_code
+ params['redirect_uri'] = REDIRECT_URI
+ params['grant_type'] = 'authorization_code'
+ request_url = AccountsUrl('o/oauth2/token')
+
+ response = urllib.request.urlopen(request_url, urllib.parse.urlencode(params).encode()).read()
+ return json.loads(response)
+
+
+def RefreshToken(client_id, client_secret, refresh_token):
+ """Obtains a new token given a refresh token.
+
+ See https://developers.google.com/accounts/docs/OAuth2InstalledApp#refresh
+
+ Args:
+ client_id: Client ID obtained by registering your app.
+ client_secret: Client secret obtained by registering your app.
+ refresh_token: A previously-obtained refresh token.
+ Returns:
+ The decoded response from the Google Accounts server, as a dict. Expected
+ fields include 'access_token', 'expires_in', and 'refresh_token'.
+ """
+ params = {}
+ params['client_id'] = client_id
+ params['client_secret'] = client_secret
+ params['refresh_token'] = refresh_token
+ params['grant_type'] = 'refresh_token'
+ request_url = AccountsUrl('o/oauth2/token')
+
+ response = urllib.request.urlopen(request_url, urllib.parse.urlencode(params).encode()).read()
+ return json.loads(response)
+
+
+def GenerateOAuth2String(username, access_token, base64_encode=True):
+ """Generates an IMAP OAuth2 authentication string.
+
+ See https://developers.google.com/google-apps/gmail/oauth2_overview
+
+ Args:
+ username: the username (email address) of the account to authenticate
+ access_token: An OAuth2 access token.
+ base64_encode: Whether to base64-encode the output.
+
+ Returns:
+ The SASL argument for the OAuth2 mechanism.
+ """
+ auth_string = 'user=%s\1auth=Bearer %s\1\1' % (username, access_token)
+ if base64_encode:
+ auth_string = base64.b64encode(bytes(auth_string, 'utf-8'))
+ return auth_string
+
+
+def TestImapAuthentication(user, auth_string):
+ """Authenticates to IMAP with the given auth_string.
+
+ Prints a debug trace of the attempted IMAP connection.
+
+ Args:
+ user: The Gmail username (full email address)
+ auth_string: A valid OAuth2 string, as returned by GenerateOAuth2String.
+ Must not be base64-encoded, since imaplib does its own base64-encoding.
+ """
+ print()
+ imap_conn = imaplib.IMAP4_SSL('imap.gmail.com')
+ imap_conn.debug = 4
+ imap_conn.authenticate('XOAUTH2', lambda x: auth_string)
+ imap_conn.select('INBOX')
+
+
+def TestSmtpAuthentication(user, auth_string):
+ """Authenticates to SMTP with the given auth_string.
+
+ Args:
+ user: The Gmail username (full email address)
+ auth_string: A valid OAuth2 string, not base64-encoded, as returned by
+ GenerateOAuth2String.
+ """
+ print()
+ smtp_conn = smtplib.SMTP('smtp.gmail.com', 587)
+ smtp_conn.set_debuglevel(True)
+ smtp_conn.ehlo('test')
+ smtp_conn.starttls()
+ smtp_conn.docmd('AUTH', 'XOAUTH2 ' + base64.b64encode(auth_string))
+
+
+def RequireOptions(options, *args):
+ missing = [arg for arg in args if getattr(options, arg) is None]
+ if missing:
+ print('Missing options: %s' % ' '.join(missing), file=sys.stderr)
+ sys.exit(-1)
+
+
+def main(argv):
+ options_parser = SetupOptionParser()
+ (options, args) = options_parser.parse_args()
+ if options.refresh_token:
+ RequireOptions(options, 'client_id', 'client_secret')
+ response = RefreshToken(options.client_id, options.client_secret,
+ options.refresh_token)
+ if options.quiet:
+ print(response['access_token'])
+ else:
+ print('Access Token: %s' % response['access_token'])
+ print('Access Token Expiration Seconds: %s' % response['expires_in'])
+ elif options.generate_oauth2_string:
+ RequireOptions(options, 'user', 'access_token')
+ oauth2_string = GenerateOAuth2String(options.user, options.access_token)
+ if options.quiet:
+ print(oauth2_string.decode('utf-8'))
+ else:
+ print('OAuth2 argument:\n' + oauth2_string.decode('utf-8'))
+ elif options.generate_oauth2_token:
+ RequireOptions(options, 'client_id', 'client_secret')
+ print('To authorize token, visit this url and follow the directions:')
+ print(' %s' % GeneratePermissionUrl(options.client_id, options.scope))
+ authorization_code = input('Enter verification code: ')
+ response = AuthorizeTokens(options.client_id, options.client_secret,
+ authorization_code)
+ print('Refresh Token: %s' % response['refresh_token'])
+ print('Access Token: %s' % response['access_token'])
+ print('Access Token Expiration Seconds: %s' % response['expires_in'])
+ elif options.test_imap_authentication:
+ RequireOptions(options, 'user', 'access_token')
+ TestImapAuthentication(options.user,
+ GenerateOAuth2String(options.user, options.access_token,
+ base64_encode=False))
+ elif options.test_smtp_authentication:
+ RequireOptions(options, 'user', 'access_token')
+ TestSmtpAuthentication(options.user,
+ GenerateOAuth2String(options.user, options.access_token,
+ base64_encode=False))
+ else:
+ options_parser.print_help()
+ print('Nothing to do, exiting.')
+ return
+
+
+if __name__ == '__main__':
+ main(sys.argv)