File fetchmail-add-contrib-fetchnmail-oauth2.py-token-acquisition-u.patch of Package fetchmail.22404
From: Matthew Ogilvie <mmogilvi+fml@zoho.com>
Date: Thu, 1 Jun 2017 00:09:02 -0600
Subject: add contrib/fetchnmail-oauth2.py token acquisition utility
Git-repo: https://gitlab.com/fetchmail/fetchmail.git
Git-commit: c82625858682eb2396b6a49da79e403c6f2b018b
---
contrib/README | 6
contrib/fetchmail-oauth2.py | 567 ++++++++++++++++++++++++++++++++++++++++++++
fetchmail.man | 3
3 files changed, 575 insertions(+), 1 deletion(-)
create mode 100755 contrib/fetchmail-oauth2.py
--- a/contrib/README
+++ b/contrib/README
@@ -181,6 +181,12 @@ sendmail 8.11.0 with multidrop.
Watchdog script to check whether fetchmail is working in daemon mode.
+### fetchmail-oauth2.py
+
+Script to obtain oauth2 access tokens that "fetchmail --auth oauthbearer"
+expects in place of the password. See --help and comments in the
+script, as well as fetchmail --auth documentation.
+
### mold-remover.py
A short python script to remove old read mail from a pop3 mailserver.
--- /dev/null
+++ b/contrib/fetchmail-oauth2.py
@@ -0,0 +1,567 @@
+#!/usr/bin/python
+#
+# Updates: Copyright 2017 Matthew Ogilvie (mogilvie+fml at zoho.com)
+# - Started with https://github.com/google/gmail-oauth2-tools.git
+# commit 45c39795044c604ed126205806191a8473c0f671 dated
+# 2015-06-09.
+# - Add file interaction (--refresh, --auto_refresh,
+# --obtain_refresh_token_file and related options).
+# - Support both python 2 and 3.
+# - Keeping the same license (below).
+#
+# Copyright 2012 Google Inc.
+#
+# 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.
+
+###############
+# POSSIBLE IMPROVEMENTS:
+#
+# FUTURE: Explicitly track expiration time of access tokens,
+# and base --auto_refresh on actual expiration time instead of
+# simple age.
+# FUTURE: Add a mode that can print the access token by itself to
+# stdout, presumably piped into fetchmail or similar (either both
+# launched by a wrapper script, this launches fetchmail, or fetchmail
+# launches this).
+# FUTURE: Mix old and new interfaces (or get rid of old interface):
+# Support using a config file to supply some of the details
+# for the original google modes of operation (--generate_oauth2_token,
+# --generate_oauth2_string, --refresh_token, and --test_*).
+# Also support providing sensative data on the command line instead
+# of files for the new modes of operation, despite the lack
+# of security (process listings, .bash_history files, etc).
+# FUTURE: Revise model for how to set permissions on updated files?
+# Preserve existing? Somehow allow setting UID/GID? Warn if files
+# are accessible by anyone but the current user?
+
+"""Performs client tasks for testing IMAP OAuth2 authentication.
+
+This documentation and examples is for gmail. For other providers,
+you will likely need to track down appropriate non-default settings
+for auth_url, token_url, and scope.
+
+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 and
+https://developers.google.com/identity/sign-in/web/devconsole-project
+for instructions on registering and for documentation of the APIs
+invoked by this code.
+
+This script has 2 main modes of operation.
+
+1. The first mode is used to generate and authorize an OAuth2 token, the
+first step in logging in via OAuth2.
+
+First, after registering your "application" (above) you should setup a
+configuration file. Use a text editor to do the command-line equivalent of:
+
+ sed 's/^ *//' > /path/to/oauth2Config.properties << EOF
+ client_id=1038[...].apps.googleusercontent.com
+ client_secret=VWFn8LIKAMC-MsjBMhJeOplZ
+ refresh_token_file=/home/path/to/refresh_token_file
+ access_token_file=/home/path/to/access_token_file
+EOF
+
+ chmod 600 /path/to/oauth2Config.properties
+
+Then run the following, and repeat any time the refresh token stops
+working, such as when you change your password. This is interactive
+and requires a web browser to complete:
+
+ oauth2 -c /path/to/oauth2Config.properties --obtain_refresh_token_file
+
+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, which will then save access and referesh
+tokens to the corresponding files for later use.
+
+Also, you'll usually need to configure fetchmail by
+including a section like the following in your .fetchmailrc:
+
+ poll imap.gmail.com protocol imap
+ auth oauthbearer username "USER@gmail.com"
+ passwordfile "/home/path/to/access_token_file"
+ is LOCALUSER here sslmode wrapped sslcertck
+
+Alternative for debugging: You can also use the original google
+script interface to obtain these tokens without involving files:
+
+ oauth2 \
+ --client_id=1038[...].apps.googleusercontent.com \
+ --client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \
+ --generate_oauth2_token
+
+
+-----
+2. The script will generate new access tokens using a refresh token.
+
+This uses the same config file setup above.
+
+ oauth2 -c /path/to/oauth2Config.properties --auto_refresh
+ # Or force refresh by using --refresh instead of --auto_refresh.
+
+ fetchmail -s # or other tools configured to use the access_token_file
+ # And/or call something to update outgoing MTA relay configuration,
+ # if necessary.
+
+You may put this sequence in a short shell script,
+and configure cron to call it a few times per hour.
+
+Alternative for debugging: You can also use the original google
+script interface to refresh the token without involving files:
+
+ oauth2 \
+ --client_id=1038[...].apps.googleusercontent.com \
+ --client_secret=VWFn8LIKAMC-MsjBMhJeOplZ \
+ --refresh_token=1/Yzm6MRy4q1xi7Dx2DuWXNgT6s37OrP_DW_IoyTum4YA
+
+-----
+Google's non-file script interface also supports a few other
+testing modes; see --help.
+"""
+
+from __future__ import print_function
+import base64
+import imaplib
+import json
+from optparse import OptionParser
+import smtplib
+import sys
+import os
+import time
+
+try:
+ import urllib.request as urlopen
+ import urllib.parse as urlparse
+except ImportError:
+ import urllib as urlopen
+ import urllib as urlparse
+
+try: input = raw_input
+except NameError: pass
+
+
+def SetupOptionParser():
+ # Usage message is the module's docstring.
+ parser = OptionParser(usage=__doc__)
+ parser.add_option('-c', '--config_file',
+ default=None,
+ help='Configuration file for --refresh '
+ 'and --obtain_refresh_token_file.\n'
+ 'The file should contain 4 (or more) settings, '
+ 'one per line, or they can also be overridden '
+ 'by the equivalent options:\n'
+ ' client_id=...\n'
+ ' client_secret=...\n'
+ ' refresh_token_file=/path/to/...\n'
+ ' access_token_file=/path/to/...\n'
+ ' Also max_age_sec, scope, umask, auth_url, and'
+ ' token_url have reasonable defaults for google.')
+ parser.add_option('--auto_refresh',
+ action='store_const',
+ default=None,
+ const=1,
+ dest='refresh',
+ help='Automatically refresh access_token_file, '
+ 'if older than max_age_sec from '
+ 'required -c /file/ info.');
+ parser.add_option('--refresh',
+ action='store_const',
+ const=2,
+ dest='refresh',
+ help='Refresh access_token_file '
+ 'unconditionally. Requires -c /file/ info.');
+ parser.add_option('--obtain_refresh_token_file',
+ action='store_true',
+ dest='obtain_refresh_token_file',
+ default=None,
+ help='Update refresh token in file. This is '
+ 'interactive, and requires '
+ 'a web browser. Also requires -c /file/ info. '
+ 'This also saves an initial access_token_file.');
+ 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_file',
+ default=None,
+ help='File name containing OAuth2 access token')
+ parser.add_option('--refresh_token_file',
+ default=None,
+ help='File name containing OAuth2 refresh token')
+ parser.add_option('--max_age_sec',
+ default=None, # manual default 3000
+ help='default max file age for --auto_refresh. '
+ 'Defaults to 3000 (10 minutes short of '
+ 'normal 3600 sec token expiration).')
+ parser.add_option('--umask',
+ default=None, # manual default 0077
+ help='default umask for --auto_refresh and '
+ '--obtain_refresh_token_file. Defaults to 0077.')
+ parser.add_option('--scope',
+ default=None, # manual default='https://mail.google.com/'
+ help='scope for the access token. Multiple scopes can be '
+ 'listed separated by spaces with the whole argument '
+ 'quoted. Defaults to https://mail.google.com/')
+ parser.add_option('--auth_url',
+ default=None, # manual default...
+ help='Permission URL for --obtain_refresh_token_file. '
+ 'Defaults to https://accounts.google.com/o/oauth2/auth.')
+ parser.add_option('--token_url',
+ default=None, # manual default...
+ help='Token URL for --obtain_refresh_token_file,'
+ ' and --refresh. '
+ 'Defaults to https://accounts.google.com/o/oauth2/token.')
+ parser.add_option('--generate_oauth2_token',
+ action='store_true',
+ dest='generate_oauth2_token',
+ help='(OLD/testing) generates an OAuth2 token for testing.'
+ ' Ignores all files.')
+ parser.add_option('--refresh_token',
+ default=None,
+ help='(OLD/testing) Generate a new access token using'
+ ' this OAuth2 refresh token. Ignores all files.')
+ parser.add_option('--user',
+ default=None,
+ help='(OLD/testing) email address of user whose account'
+ ' is being accessed')
+ parser.add_option('--access_token',
+ default=None,
+ help='(OLD/testing) OAuth2 access token.')
+ parser.add_option('--generate_oauth2_string',
+ action='store_true',
+ dest='generate_oauth2_string',
+ help='(OLD/testing) generates an initial client response'
+ ' string for OAuth2. Ignores all files.')
+ parser.add_option('--test_imap_authentication',
+ action='store_true',
+ dest='test_imap_authentication',
+ help='(OLD/testing) attempts to authenticate to IMAP. '
+ 'Ignores all files.')
+ parser.add_option('--test_smtp_authentication',
+ action='store_true',
+ dest='test_smtp_authentication',
+ help='(OLD/testing) attempts to authenticate to SMTP. '
+ 'Ignores all files.')
+ return parser
+
+
+# Hardcoded dummy redirect URI for non-web apps.
+REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
+
+
+def UrlEscape(text):
+ # See OAUTH 5.1 for a definition of which characters need to be escaped.
+ return urlparse.quote(text, safe='~-._')
+
+
+def UrlUnescape(text):
+ # See OAUTH 5.1 for a definition of which characters need to be escaped.
+ return urlparse.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(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, auth_url):
+ """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.
+ """
+ if not scope:
+ scope = 'https://mail.google.com/'
+ if not auth_url:
+ auth_url = 'https://accounts.google.com/o/oauth2/auth'
+ params = {}
+ params['client_id'] = client_id
+ params['redirect_uri'] = REDIRECT_URI
+ params['scope'] = scope
+ params['response_type'] = 'code'
+ return '%s?%s' % (auth_url, FormatUrlParams(params))
+
+
+def AuthorizeTokens(client_id, client_secret, authorization_code, token_url):
+ """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'
+ if not token_url:
+ token_url = 'https://accounts.google.com/o/oauth2/token'
+
+ response = urlopen.urlopen(token_url,
+ urlparse.urlencode(params).encode('ascii')).read()
+ return json.loads(response.decode("utf-8"))
+
+
+def RefreshToken(client_id, client_secret, refresh_token, token_url):
+ """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'
+ if not token_url:
+ token_url = 'https://accounts.google.com/o/oauth2/token'
+
+ response = urlopen.urlopen(token_url,
+ urlparse.urlencode(params).encode('ascii')).read()
+ return json.loads(response.decode("utf-8"))
+
+
+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(auth_string)
+ 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))
+ sys.exit(-1)
+
+def parseConfigFile(options):
+ if options.config_file:
+ cfg = dict(line.strip().split('=',1) for line in open(options.config_file))
+ else:
+ cfg = { }
+ # defaults:
+ if not 'scope' in cfg:
+ cfg['scope'] = 'https://mail.google.com/'
+ if not 'max_age_sec' in cfg:
+ cfg['max_age_sec'] = '3000'
+ if not 'umask' in cfg:
+ cfg['umask'] = '0077'
+ if not 'auth_url' in cfg:
+ cfg['auth_url'] = 'https://accounts.google.com/o/oauth2/auth'
+ if not 'token_url' in cfg:
+ cfg['token_url'] = 'https://accounts.google.com/o/oauth2/token'
+ # overrides (from command line):
+ for arg in [ 'scope', 'client_id', 'client_secret', 'umask',
+ 'max_age_sec', 'access_token_file', 'refresh_token_file',
+ 'auth_url', 'token_url' ]:
+ if getattr(options,arg):
+ cfg[arg] = getattr(options,arg)
+ return cfg
+
+def requireConfig(cfg, *args):
+ missing = [arg for arg in args if not arg in cfg]
+ if missing:
+ print('Missing options: %s' % ' '.join(missing))
+ sys.exit(-1)
+
+
+def main(argv):
+ options_parser = SetupOptionParser()
+ (options, args) = options_parser.parse_args()
+ if options.refresh:
+ cfg = parseConfigFile(options)
+ requireConfig(cfg, 'refresh_token_file', 'access_token_file',
+ 'client_id', 'client_secret', 'umask')
+ st = os.stat(cfg['access_token_file'])
+ if options.refresh < 2:
+ requireConfig(cfg, 'max_age_sec')
+ if time.time()-st.st_mtime < int(cfg['max_age_sec']):
+ return
+ with open(cfg['refresh_token_file'],"r") as f:
+ reftok = f.readline().rstrip()
+ if len(reftok) == 0:
+ print('refresh token is empty')
+ sys.exit(-1)
+ response = RefreshToken(cfg['client_id'],cfg['client_secret'],reftok,
+ cfg['token_url'])
+ newTok = response['access_token']
+ if len(newTok) == 0:
+ print('failed to obtain access token: it is empty')
+ sys.exit(-1)
+ savedUmask = os.umask(int(cfg['umask'],8))
+ try:
+ with open(cfg['access_token_file']+".tmp","w") as f:
+ f.write(newTok)
+ f.write('\n')
+ os.rename(cfg['access_token_file']+".tmp",cfg['access_token_file'])
+ finally:
+ os.umask(savedUmask)
+ elif options.obtain_refresh_token_file:
+ cfg = parseConfigFile(options)
+ requireConfig(cfg, 'refresh_token_file', 'access_token_file',
+ 'client_id', 'client_secret', 'umask')
+ print('To authorize token, visit this url and follow the directions:')
+ print(' %s' % GeneratePermissionUrl(cfg['client_id'], cfg['scope'],
+ cfg['auth_url']))
+ authorization_code = input('Enter verification code: ')
+ response = AuthorizeTokens(cfg['client_id'], cfg['client_secret'],
+ authorization_code, cfg['token_url'])
+ newRefTok = response['refresh_token']
+ if len(newRefTok) == 0:
+ print('failed to obtain refresh token: it is empty')
+ sys.exit(-1)
+ newTok = response['access_token']
+ if len(newTok) == 0:
+ print('failed to obtain corresponding access token: it is empty')
+ sys.exit(-1)
+ savedUmask = os.umask(int(cfg['umask'],8))
+ try:
+ with open(cfg['refresh_token_file']+".tmp","w") as f:
+ f.write(newRefTok)
+ f.write('\n')
+ os.rename(cfg['refresh_token_file']+".tmp",cfg['refresh_token_file'])
+ with open(cfg['access_token_file']+".tmp","w") as f:
+ f.write(newTok)
+ f.write('\n')
+ print("Refresh token saved to '%s'" % cfg['refresh_token_file'])
+ print("Initial access token saved to '%s'" % cfg['access_token_file'])
+ print('Access Token Expiration Seconds: %s' % response['expires_in'])
+ os.rename(cfg['access_token_file']+".tmp",cfg['access_token_file'])
+ finally:
+ os.umask(savedUmask)
+
+ ##### (OLD/testing options)
+
+ elif options.refresh_token:
+ RequireOptions(options, 'client_id', 'client_secret')
+ response = RefreshToken(options.client_id, options.client_secret,
+ options.refresh_token, options.token_url)
+ 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')
+ print ('OAuth2 argument:\n' +
+ GenerateOAuth2String(options.user, options.access_token))
+ 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,
+ options.auth_url))
+ authorization_code = input('Enter verification code: ')
+ response = AuthorizeTokens(options.client_id, options.client_secret,
+ authorization_code, options.token_url)
+ 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)
--- a/fetchmail.man
+++ b/fetchmail.man
@@ -1062,7 +1062,8 @@ External tools are necessary to obtain
such tokens. Access tokens often expire fairly quickly (e.g. 1 hour),
and new ones need to be generated from renewal tokens, so the
"passwordfile", "passwordfd", or "pwmd_*" options may be useful. See the
-oauth2.py script from
+contrib/fetchmail-oauth2.py script from the fetchmail source code, which
+was derived from code associated with
.URL https://github.com/google/gmail-oauth2-tools/wiki/OAuth2DotPyRunThrough "Google's Oauth2 Run Through" ,
and other oauth2 documentation. For services like gmail, an "App Password"
is probably preferable if available, because it has roughly the same