summaryrefslogtreecommitdiffstats
path: root/contrib
diff options
context:
space:
mode:
authorAlexander Perlis <aperlis@math.lsu.edu>2020-08-21 15:22:50 -0700
committerKevin McCarthy <kevin@8t8.us>2020-08-21 15:22:50 -0700
commitc0218adedc6eb420a656d9d061933d93f66126e7 (patch)
treec8517e9fac24ed0f3da8e1e9c8ead09bd0bb37f1 /contrib
parenta563ce85312fca4fe3fbdb628f4ae261c61ce8cc (diff)
Updates to contrib/mutt_oauth2.py and README.
The newer version of the mutt_oauth2.py script incorporates the following changes: - Uses /usr/bin/env at top - Many formatting changes to appease pylint - Improvement to POP test error message output Also attached is a README that has instructions for both Microsoft and Google.
Diffstat (limited to 'contrib')
-rw-r--r--contrib/mutt_oauth2.py197
-rw-r--r--contrib/mutt_oauth2.py.README315
2 files changed, 396 insertions, 116 deletions
diff --git a/contrib/mutt_oauth2.py b/contrib/mutt_oauth2.py
index bfc35288..4fd632ac 100644
--- a/contrib/mutt_oauth2.py
+++ b/contrib/mutt_oauth2.py
@@ -1,6 +1,7 @@
-#!/usr/bin/python3
+#!/usr/bin/env python3
#
-# Mutt OAuth2 token management script, version 2020-07-06
+# Mutt OAuth2 token management script, version 2020-08-07
+# Written against python 3.7.3, not tried with earlier python versions.
#
# Copyright (C) 2020 Alexander Perlis
#
@@ -18,6 +19,7 @@
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.
+'''Mutt OAuth2 token management'''
import sys
import json
@@ -37,11 +39,13 @@ import socket
import http.server
import subprocess
-# The token file must be encrypted because it contains multi-use bearer tokens whose usage does not require
-# additional verification. Specify whichever encryption and decryption pipes you prefer. They should read
-# from standard input and write to standard output.
-ENCRYPTION_PIPE=['gpg', '--encrypt', '--recipient', 'YOUR_GPG_IDENTITY']
-DECRYPTION_PIPE=['gpg', '--decrypt']
+# The token file must be encrypted because it contains multi-use bearer tokens
+# whose usage does not require additional verification. Specify whichever
+# encryption and decryption pipes you prefer. They should read from standard
+# input and write to standard output. The example values here invoke GPG,
+# although won't work until an appropriate identity appears in the first line.
+ENCRYPTION_PIPE = ['gpg', '--encrypt', '--recipient', 'YOUR_GPG_IDENTITY']
+DECRYPTION_PIPE = ['gpg', '--decrypt']
registrations = {
'google': {
@@ -67,7 +71,9 @@ registrations = {
'pop_endpoint': 'outlook.office365.com',
'smtp_endpoint': 'smtp.office365.com',
'sasl_method': 'XOAUTH2',
- 'scope': 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/POP.AccessAsUser.All https://outlook.office.com/SMTP.Send',
+ 'scope': ('offline_access https://outlook.office.com/IMAP.AccessAsUser.All '
+ 'https://outlook.office.com/POP.AccessAsUser.All '
+ 'https://outlook.office.com/SMTP.Send'),
'client_id': '',
'client_secret': '',
},
@@ -91,28 +97,36 @@ args = ap.parse_args()
token = {}
path = Path(args.tokenfile)
if path.exists():
- if 0o600 != 0o777 & path.stat().st_mode:
+ if 0o777 & path.stat().st_mode != 0o600:
sys.exit('Token file has unsafe mode. Suggest deleting and starting over.')
try:
- sub = subprocess.run(DECRYPTION_PIPE, check=True, input=path.read_bytes(), capture_output=True)
+ sub = subprocess.run(DECRYPTION_PIPE, check=True, input=path.read_bytes(),
+ capture_output=True)
token = json.loads(sub.stdout)
except subprocess.CalledProcessError:
- sys.exit('Difficulty decrypting token file. Is your decryption agent primed for non-interactive usage, or an appropriate environment variable such as GPG_TTY set to allow interactive agent usage from inside a pipe?')
+ sys.exit('Difficulty decrypting token file. Is your decryption agent primed for '
+ 'non-interactive usage, or an appropriate environment variable such as '
+ 'GPG_TTY set to allow interactive agent usage from inside a pipe?')
def writetokenfile():
- if not path.exists(): path.touch(mode=0o600)
- if 0o600 != 0o777 & path.stat().st_mode:
+ '''Writes global token dictionary into token file.'''
+ if not path.exists():
+ path.touch(mode=0o600)
+ if 0o777 & path.stat().st_mode != 0o600:
sys.exit('Token file has unsafe mode. Suggest deleting and starting over.')
- sub = subprocess.run(ENCRYPTION_PIPE, check=True, input=json.dumps(token).encode(), capture_output=True)
- path.write_bytes(sub.stdout)
+ sub2 = subprocess.run(ENCRYPTION_PIPE, check=True, input=json.dumps(token).encode(),
+ capture_output=True)
+ path.write_bytes(sub2.stdout)
-if args.debug: print('Obtained from token file:', json.dumps(token))
+if args.debug:
+ print('Obtained from token file:', json.dumps(token))
if not token:
if not args.authorize:
sys.exit('You must run script with "--authorize" at least once.')
print('Available app and endpoint registrations:', *registrations)
token['registration'] = input('OAuth2 registration: ')
- token['authflow'] = input('Preferred OAuth2 flow ("authcode" or "localhostauthcode" or "devicecode"): ')
+ token['authflow'] = input('Preferred OAuth2 flow ("authcode" or "localhostauthcode" '
+ 'or "devicecode"): ')
token['email'] = input('Account e-mail address: ')
token['access_token'] = ''
token['access_token_expiration'] = ''
@@ -120,30 +134,38 @@ if not token:
writetokenfile()
if token['registration'] not in registrations:
- sys.exit(f'ERROR: Unknown registration "{token["registration"]}". Delete token file and start over.')
+ sys.exit(f'ERROR: Unknown registration "{token["registration"]}". Delete token file '
+ f'and start over.')
registration = registrations[token['registration']]
authflow = token['authflow']
-if args.authflow: authflow = args.authflow
+if args.authflow:
+ authflow = args.authflow
baseparams = {'client_id': registration['client_id']}
# Microsoft uses 'tenant' but Google does not
-if 'tenant' in registration: baseparams['tenant'] = registration['tenant']
+if 'tenant' in registration:
+ baseparams['tenant'] = registration['tenant']
def access_token_valid():
- a = token['access_token_expiration']
- return a and datetime.now() < datetime.fromisoformat(a)
+ '''Returns True when stored access token exists and is still valid at this time.'''
+ token_exp = token['access_token_expiration']
+ return token_exp and datetime.now() < datetime.fromisoformat(token_exp)
def update_tokens(r):
+ '''Takes a response dictionary, extracts tokens out of it, and updates token file.'''
token['access_token'] = r['access_token']
- token['access_token_expiration'] = (datetime.now() + timedelta(seconds=int(r['expires_in']))).isoformat()
- if 'refresh_token' in r: token['refresh_token'] = r['refresh_token']
+ token['access_token_expiration'] = (datetime.now() +
+ timedelta(seconds=int(r['expires_in']))).isoformat()
+ if 'refresh_token' in r:
+ token['refresh_token'] = r['refresh_token']
writetokenfile()
- if args.verbose: print(f'NOTICE: Obtained new access token, expires {token["access_token_expiration"]}.')
+ if args.verbose:
+ print(f'NOTICE: Obtained new access token, expires {token["access_token_expiration"]}.')
if args.authorize:
- p = baseparams.copy();
+ p = baseparams.copy()
p['scope'] = registration['scope']
if authflow in ('authcode', 'localhostauthcode'):
@@ -159,71 +181,86 @@ if args.authorize:
s.close()
redirect_uri = 'http://localhost:'+str(port)+'/'
# Probably should edit the port number into the actual redirect URL.
-
+
p.update({'login_hint': token['email'],
'response_type': 'code',
'redirect_uri': redirect_uri,
'code_challenge': challenge,
'code_challenge_method': 'S256'})
- print(registration["authorize_endpoint"]+'?'+urllib.parse.urlencode(p, quote_via=urllib.parse.quote))
+ print(registration["authorize_endpoint"] + '?' +
+ urllib.parse.urlencode(p, quote_via=urllib.parse.quote))
authcode = ''
if authflow == 'authcode':
- authcode = input('Visit displayed URL to retrieve authorization code. Enter code from server (might be in browser address bar): ')
+ authcode = input('Visit displayed URL to retrieve authorization code. Enter '
+ 'code from server (might be in browser address bar): ')
else:
- print('Visit displayed URL to authorize this application. Waiting...', end='', flush=True)
+ print('Visit displayed URL to authorize this application. Waiting...',
+ end='', flush=True)
class MyHandler(http.server.BaseHTTPRequestHandler):
+ '''Handles the browser query resulting from redirect to redirect_uri.'''
def do_HEAD(self):
+ '''Response to a HEAD requests.'''
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
def do_GET(self):
+ '''For GET request, extract code parameter from URL.'''
global authcode
querystring = urllib.parse.urlparse(self.path).query
querydict = urllib.parse.parse_qs(querystring)
- if 'code' in querydict: authcode = querydict['code'][0]
+ if 'code' in querydict:
+ authcode = querydict['code'][0]
self.do_HEAD()
self.wfile.write(b'<html><head><title>Authorizaton result</title></head>')
- self.wfile.write(b'<body><p>Authorization redirect completed. You may close this window.</p></body></html>')
- with http.server.HTTPServer(('127.0.0.1',port), MyHandler) as httpd:
- try: httpd.handle_request()
- except KeyboardInterrupt: pass
-
+ self.wfile.write(b'<body><p>Authorization redirect completed. You may '
+ b'close this window.</p></body></html>')
+ with http.server.HTTPServer(('127.0.0.1', port), MyHandler) as httpd:
+ try:
+ httpd.handle_request()
+ except KeyboardInterrupt:
+ pass
+
if not authcode:
sys.exit('Did not obtain an authcode.')
- for k in 'response_type', 'login_hint', 'code_challenge', 'code_challenge_method': del p[k]
+ for k in 'response_type', 'login_hint', 'code_challenge', 'code_challenge_method':
+ del p[k]
p.update({'grant_type': 'authorization_code',
'code': authcode,
'client_secret': registration['client_secret'],
'code_verifier': verifier})
try:
response = urllib.request.urlopen(registration['token_endpoint'],
- urllib.parse.urlencode(p).encode())
+ urllib.parse.urlencode(p).encode())
except urllib.error.HTTPError as err:
print(err.code, err.reason)
response = err
response = response.read()
- if args.debug: print(response)
+ if args.debug:
+ print(response)
response = json.loads(response)
if 'error' in response:
print(response['error'])
- if 'error_description' in response: print(response['error_description'])
+ if 'error_description' in response:
+ print(response['error_description'])
sys.exit(1)
elif authflow == 'devicecode':
try:
response = urllib.request.urlopen(registration['devicecode_endpoint'],
- urllib.parse.urlencode(p).encode())
+ urllib.parse.urlencode(p).encode())
except urllib.error.HTTPError as err:
print(err.code, err.reason)
response = err
response = response.read()
- if args.debug: print(response)
+ if args.debug:
+ print(response)
response = json.loads(response)
if 'error' in response:
print(response['error'])
- if 'error_description' in response: print(response['error_description'])
+ if 'error_description' in response:
+ print(response['error_description'])
sys.exit(1)
print(response['message'])
del p['scope']
@@ -237,14 +274,16 @@ if args.authorize:
print('.', end='', flush=True)
try:
response = urllib.request.urlopen(registration['token_endpoint'],
- urllib.parse.urlencode(p).encode())
+ urllib.parse.urlencode(p).encode())
except urllib.error.HTTPError as err:
# Not actually always an error, might just mean "keep trying..."
response = err
response = response.read()
- if args.debug: print(response)
+ if args.debug:
+ print(response)
response = json.loads(response)
- if 'error' not in response: break
+ if 'error' not in response:
+ break
if response['error'] == 'authorization_declined':
print(' user declined authorization.')
sys.exit(1)
@@ -253,18 +292,22 @@ if args.authorize:
sys.exit(1)
if response['error'] != 'authorization_pending':
print(response['error'])
- if 'error_description' in response: print(response['error_description'])
+ if 'error_description' in response:
+ print(response['error_description'])
sys.exit(1)
print()
else:
- sys.exit(f'ERROR: Unknown OAuth2 flow "{token["authflow"]}. Delete token file and start over.')
+ sys.exit(f'ERROR: Unknown OAuth2 flow "{token["authflow"]}. Delete token file and '
+ f'start over.')
update_tokens(response)
if not access_token_valid():
- if args.verbose: print('NOTICE: Invalid or expired access token; using refresh token to obtain new access token.')
+ if args.verbose:
+ print('NOTICE: Invalid or expired access token; using refresh token '
+ 'to obtain new access token.')
if not token['refresh_token']:
sys.exit(f'ERROR: No refresh token. Run script with "--authorize".')
p = baseparams.copy()
@@ -273,16 +316,18 @@ if not access_token_valid():
'grant_type': 'refresh_token'})
try:
response = urllib.request.urlopen(registration['token_endpoint'],
- urllib.parse.urlencode(p).encode())
+ urllib.parse.urlencode(p).encode())
except urllib.error.HTTPError as err:
print(err.code, err.reason)
response = err
response = response.read()
- if args.debug: print(response)
+ if args.debug:
+ print(response)
response = json.loads(response)
if 'error' in response:
print(response['error'])
- if 'error_description' in response: print(response['error_description'])
+ if 'error_description' in response:
+ print(response['error_description'])
print(f'Perhaps refresh token invalid. Try running once with "--authorize"')
sys.exit(1)
update_tokens(response)
@@ -292,24 +337,28 @@ if not access_token_valid():
sys.exit('ERROR: No valid access token. This should not be able to happen.')
-if args.verbose: print('Access Token: ', end='')
+if args.verbose:
+ print('Access Token: ', end='')
print(token['access_token'])
-
-def build_sasl_string(user,host,port,token):
+
+def build_sasl_string(user, host, port, token):
+ '''Build appropriate SASL string, which depends on cloud server's supported SASL method.'''
if registration['sasl_method'] == 'OAUTHBEARER':
return f'n,a={user},\1host={host}\1port={port}\1auth=Bearer {token}\1\1'
- elif registration['sasl_method'] == 'XOAUTH2':
+ if registration['sasl_method'] == 'XOAUTH2':
return f'user={user}\1auth=Bearer {token}\1\1'
- else: sys.exit(f'Unknown SASL method {registration["sasl_method"]}.')
+ sys.exit(f'Unknown SASL method {registration["sasl_method"]}.')
+
-
if args.test:
errors = False
imap_conn = imaplib.IMAP4_SSL(registration['imap_endpoint'])
- sasl_string = build_sasl_string(token['email'], registration['imap_endpoint'], 993, token['access_token'])
- if args.debug: imap_conn.debug = 4
+ sasl_string = build_sasl_string(token['email'], registration['imap_endpoint'], 993,
+ token['access_token'])
+ if args.debug:
+ imap_conn.debug = 4
try:
imap_conn.authenticate(registration['sasl_method'], lambda _: sasl_string.encode())
# Microsoft has a bug wherein a mismatch between username and token can still report a
@@ -317,37 +366,45 @@ if args.test:
# Fortunately subsequent commands fail with an error. Thus we follow AUTH with another
# IMAP command before reporting success.
imap_conn.list()
- if args.verbose: print('IMAP authentication succeeded')
+ if args.verbose:
+ print('IMAP authentication succeeded')
except imaplib.IMAP4.error as e:
print('IMAP authentication FAILED (does your account allow IMAP?):', e)
errors = True
pop_conn = poplib.POP3_SSL(registration['pop_endpoint'])
- sasl_string = build_sasl_string(token['email'], registration['pop_endpoint'], 995, token['access_token'])
- if args.debug: pop_conn.set_debuglevel(2)
+ sasl_string = build_sasl_string(token['email'], registration['pop_endpoint'], 995,
+ token['access_token'])
+ if args.debug:
+ pop_conn.set_debuglevel(2)
try:
# poplib doesn't have an auth command taking an authenticator object
# Microsoft requires a two-line SASL for POP
pop_conn._shortcmd('AUTH ' + registration['sasl_method'])
pop_conn._shortcmd(base64.standard_b64encode(sasl_string.encode()).decode())
- if args.verbose: print('POP authentication succeeded')
+ if args.verbose:
+ print('POP authentication succeeded')
except poplib.error_proto as e:
- print('POP authentication FAILED (does your account allow POP?):', e)
+ print('POP authentication FAILED (does your account allow POP?):', e.args[0].decode())
errors = True
# SMTP_SSL would be simpler but Microsoft does not answer on port 465.
smtp_conn = smtplib.SMTP(registration['smtp_endpoint'], 587)
- sasl_string = build_sasl_string(token['email'], registration['smtp_endpoint'], 587, token['access_token'])
+ sasl_string = build_sasl_string(token['email'], registration['smtp_endpoint'], 587,
+ token['access_token'])
smtp_conn.ehlo('test')
smtp_conn.starttls()
smtp_conn.ehlo('test')
- if args.debug: smtp_conn.set_debuglevel(2)
+ if args.debug:
+ smtp_conn.set_debuglevel(2)
try:
- smtp_conn.auth(registration['sasl_method'], lambda _ = None: sasl_string)
- if args.verbose: print('SMTP authentication succeeded')
+ smtp_conn.auth(registration['sasl_method'], lambda _=None: sasl_string)
+ if args.verbose:
+ print('SMTP authentication succeeded')
except smtplib.SMTPAuthenticationError as e:
print('SMTP authentication FAILED:', e)
errors = True
- if errors: sys.exit(1)
+ if errors:
+ sys.exit(1)
diff --git a/contrib/mutt_oauth2.py.README b/contrib/mutt_oauth2.py.README
index 723705d8..ec7cb58b 100644
--- a/contrib/mutt_oauth2.py.README
+++ b/contrib/mutt_oauth2.py.README
@@ -1,67 +1,290 @@
-This is from Alexander Perlis' email to mutt-dev
-<158CA2EB-5AE4-44CB-B562-BB9B9537DF7E@math.lsu.edu>:
+mutt_oauth.py README by Alexander Perlis, 2020-07-15
+====================================================
-With the attached mutt_oauth2.py script and a corresponding app registration I successfully connected mutt to:
- - Gmail account
- - Microsoft consumer account (e.g., outlook.com)
- - Microsoft work/school account (Office365 under an Azure organizational tenant)
-An obstacle for users is the needed "app registration". With Google it didn't strike me as obvious how to create a test registration. As for a Microsoft work/school account, my institution won't even allow end users to get to the app registration screen, nor do my institutional security admins seem willing to create an in-house app registration for me to use, feeling that the software vendor should have obtained one and coded it into their product (as is the case with Microsoft Outlook, Apple Mail, and Mozilla Thunderbird). I might succeed in convincing them to approve an unofficial registration that I create myself using a consumer account, but only mutt maintainers would be able to create an official mutt registration verified by the cloud mail provider as belonging to "mutt.org". I suspect my institution is more likely to approve an official registration.
+Background on plain passwords, app passwords, OAuth2 bearer tokens
+------------------------------------------------------------------
-Ideally the mutt maintainers create an official registration and hardcode it into the script distributed with mutt, but this is surely an unreasonable expectation once one considers all the different mail providers on the planet. Perhaps for the time being this could be limited to specific mail providers requested by people on the mutt mailing list? Taking Thunderbird as an example, the version 78.0b2 source has hardcoded registrations for several major providers---see the OAuth2-related files in comm/mailnews/base/util. A source comment reveals they are exploring eventually replacing the hardcoded registrations with some type of "dynamic registration" that some mail providers might support. I don't know how that mechanism would work and whether it would be an option for mutt, by which I mean I don't know whether anyone would be able to use that mechanism or whether some entity like Mozilla Foundation will be brokering access to such a mechanism on behalf of Thunderbird.
+An auth stage occurs near the start of the IMAP/POP/SMTP protocol
+conversation. Various SASL methods can be used (depends on what the
+server offers, and what the client supports). The PLAIN method, also
+known as "basic auth", involves simply sending the username and
+password (this occurs over an encrypted connection), and used to be
+common; but, for large cloud mail providers, basic auth is a security
+hole. User passwords often have low entropy (humans generally choose
+passwords that can be produced from human memory), thus are targets
+for various types of exhaustive attacks. Older attacks try different
+passwords against one user, whereas newer spray attacks try one
+password against different users. General mitigation efforts such as
+rate-limiting, or detection and outright blocking efforts, lead to
+degraded or outright denied services for legitimate users. The
+security weakness is two-fold: the low entropy of the user password,
+together with the alarming consequence that the password often unlocks
+many disparate systems in a typical enterprise single-sign-on
+environment. Also, humans type passwords or copy/paste them from
+elsewhere on the screen, so they can also be grabbed via keyloggers or
+screen capture (or a human bystander). Two ways to solve these
+conundrums:
-Here's how to create a Microsoft registration (whether done individually by each end user, or done once and for all by the mutt maintainers). Go to portal.azure.com, log in with a Microsoft account (get a free one at outlook.com), then search for "app registration", and add a new registration. On the initial form that appears, put a name like "Mutt", and leave the other two questions at default (allow any type of account, no redirect URI), then more carefully go through each screen:
+ - app passwords
+ - bearer tokens
- Branding
- - End users might leave these fields blank
- - For official registration, verify the publisher domain by adding a retrievable file to "mutt.org"
- Authentication:
- - Platform "Mobile and desktop"
- - Redirect URI "https://login.microsoftonline.com/common/oauth2/nativeclient"
- - Any kind of account
- - Enable public client (allow device code flow)
- API permissions:
- - Microsoft Graph, Delegated, "offline_access"
- - Microsoft Graph, Delegated, "IMAP.AccessAsUser.All"
- - Microsoft Graph, Delegated, "POP.AccessAsUser.All"
- - Microsoft Graph, Delegated, "SMTP.Send"
- - Microsoft Graph, Delegated, "User.Read"
- Overview:
- - Take note of the Application ID (a.k.a. Client ID), you'll need it shortly
+App passwords are simply high-entropy protocol-specific passwords, in
+other words a long computer-generated random string, you use one for
+your mail system, a different one for your payroll system, and so
+on. With app passwords in use, brute-force attacks become useless. App
+passwords require no modifications to client software, and only minor
+changes on the server side. One way to think about app passwords is
+that they essentially impose on you the use of a password manager. Any
+user can go to the trouble of using a password manager but most users
+don't bother. App passwords put the password manager inside the server
+and force you to use it.
-End users who aren't able to get to the app registration screen within portal.azure.com for their work/school account can temporarily use an incognito browser window to create a free outlook.com account and use that to create the app registration.
+Bearer tokens take the idea of app passwords to the next level. Much
+like app passwords, they too are just long computer-generated random
+strings, knowledge of which simply "lets you in". But unlike an app
+password which the user must manually copy from a server password
+screen and then paste into their client account config screen (a
+process the user doesn't want to follow too often), bearer tokens get
+swapped out approximately once an hour without user interaction. For
+this to work, both clients and servers must be modified to speak a
+separate out-of-band protocol (the "OAuth2" protocol) to swap out
+tokens. More precisely, from start to finish, the process goes like
+this: the client and server must once-and-for-all be informed about
+each other (this is called "app registration" and might be done by the
+client developer or left to each end user), then the client informs
+the server that it wants to connect, then the user is informed to
+independently use a web browser to visit a server destination to
+approve this request (at this stage the server will require the user
+to authenticate using say their password and perhaps additional
+factors such as an SMS verification or crypto device), then the client
+will have a long-term "refresh token" as well as an "access token"
+good for about an hour. The access token can now be used with
+IMAP/POP/SMTP to access the account. When it expires, the refresh
+token is used to get a new access token and perhaps a new refresh
+token. After several months of such usage, even the refresh token may
+expire and the human user will have to go back and re-authenticate
+(password, SMS, crypto device, etc) for things to start anew.
-Edit the client ID into the mutt_oauth2.py script. Run "mutt_oauth2.py --help" to learn script usage. To obtain the initial set of tokens, run the script specifying a name for a disposable token storage file, as well as "--authorize", for example using this naming scheme:
+Since app passwords and tokens are high-entropy and their compromise
+should compromise only a particular system (rather than all systems in
+a single-sign-on environment), they have similar security strength
+when compared to stark weakness of traditional human passwords. But if
+compared only to each other, tokens provide more security. App
+passwords must be short enough for humans to easily copy/paste them,
+might get written down or snooped during that process, and anyhow are
+long-lived and thus could get compromised by other means. The main
+drawback to tokens is that their support requires significant changes
+to clients and servers, but once such support exists, they are
+superior and easier to use.
- mutt_oauth2.py userid@myschool.edu.tokens --verbose --authorize
+Many cloud providers are eliminating support for human passwords. Some are
+allowing app passwords in addition to tokens. Some allow only tokens.
-The script will ask questions and provide some instructions. For the flow question, pick one; you can go back later and delete your token file and start over with the other flow, to see which one you like more. Depending on the OAuth2 provider and how the app registration was configured, both flows might not work, but trying them is the best way to figure out what works and which one you prefer. In the "authcode" flow you paste a complicated URL into a browser, then manually extract a "code" parameter from a subsequent URL in the browser and paste that back to the script; in the "devicecode" flow you go to a simple URL and just enter a short code, no additional steps.
-If you're stuck at this point because a web browser screen says your institution admins must grant approval, either request that approval, or as a temporary punt for end-user experimentation you could "borrow" the Mozilla Thunderbird registrations mentioned earlier (but heed the comment in their source warning their experimental registration might eventually disappear). You'll need the client_id, client_secret, and redirect_uri. Before trying mutt with this borrowed registration, I suggest first configuring Thunderbird itself for your work/school account using OAuth2 (called "Modern Auth" in Microsoft marketing lingo), as you might need to seek approval from your institutional admins to get that to work. Once Thunderbird itself is working, any OAuth2-capable IMAP/POP/SMTP client using the same registration should also work (how could the server possibly tell the difference?).
+OAuth2 token support in mutt
+----------------------------
-Once you've succeeded authorizing mutt_oauth2.py to obtain tokens, try one of the following to see whether IMAP/POP/SMTP are working:
+Mutt supports the two SASL methods OAUTHBEARER and XOAUTH2 for presenting an
+OAuth2 access token near the start of the IMAP/POP/SMTP connection.
- mutt_oauth2.py userid@myschool.edu.tokens --verbose --test
- mutt_oauth2.py userid@myschool.edu.tokens --verbose --debug --test
+(Two different SASL methods exist for historical reasons. While OAuth2
+was under development, the experimental offering by servers was called
+XOAUTH2, later fleshed out into a standard named OAUTHBEARER, but not
+all servers have been updated to offer OAUTHBEARER. Once the major
+cloud providers all support OAUTHBEARER, clients like mutt might be
+modified to no longer know about XOAUTH2.)
-Without optional parameters, the script simply returns an access token (possibly first conducting a behind-the-scenes URL retrieval using a stored refresh token to obtain an updated access token). This is how the script will be used by mutt. Your muttrc would look something like:
+Mutt can present a token inside IMAP/POP/SMTP, but by design mutt itself
+does not know how to have a separate conversation (outside of IMAP/POP/SMTP)
+with the server to authorize the user and obtain refresh and access tokens.
+Mutt just needs an access token, and has a hook for an external script to
+somehow obtain one.
- set imap_user="userid@myschool.edu"
- set folder="imap://outlook.office365.com/"
- set smtp_url="smtp://${imap_user}@smtp.office365.com:587/"
- set imap_authenticators="xoauth2"
- set imap_oauth_refresh_command="/path/to/script/mutt_oauth2.py ${imap_user}.tokens"
- set smtp_authenticators=${imap_authenticators}
- set smtp_oauth_refresh_command=${imap_oauth_refresh_command}
+mutt_oauth2.py is an example of such an external script. It likely can be
+adapted to work with OAuth2 on many different cloud mail providers, and has
+been tested against:
-I didn't navigate creating my own registration at Google, instead tested the script against Google by borrowing the Thunderbird registration and using the authcode flow. Using a Microsoft consumer account I could easily create my own registration, and tested both the authcode flow and the devicecode flow. For a Microsoft work/school account I needed a registration approved by my institution, so I again borrowed the Thunderbird registration and could test the authcode flow.
+ - Google consumer account (@gmail.com)
+ - Google work/school account (G Suite tenant)
+ - Microsoft consumer account (e.g., @live.com, @outlook.com, ...)
+ - Microsoft work/school account (Azure tenant)
+ (Note that Microsoft uses the marketing term "Modern Auth" in lieu of
+ "OAuth2". In that terminology, mutt indeed supports "Modern Auth".)
-Security considerations and roadmap for improvement:
- Any client mechanism for OAuth2 will want to somehow store the long-term refresh token, otherwise the user will have to go through the entire authorization gauntlet upon each client invocation. Ideally the tokens would be kept in an encrypted store that must first be unlocked by the user in a way that limits which client can access the unlocked tokens---instead of reinventing the wheel here, leveraging OS-provided crypto stores is appealing but doing so in a platform-independent way may be challenging at present. These considerations aren't specific to mutt, but in the case of mutt there would be increased opportunity for security and improved user experience as follows. At a minimum, mutt itself could hold in memory the current access token, reusing it until it expires, relying on the external script only at time of such expiration. That way a user could choose not to store refresh tokens at all (and servers might not even hand them out in the first place), and no tokens will leak. Even better if mutt can also keep the optional refresh token in memory, simply to be passed back and forth between mutt and the script, thus allowing the script to maintain some "state" between invocations, thereby allowing mutt sessions to go longer than the life of one access token (an hour?), yet still not leaking tokens. Finally, somehow allowing the script to interact with the user would be nice, as then the script could usually just return a new access token if the script already has a valid refresh token, but otherwise immediately interact with the user to complete an authcode flow or devicecode flow---from the user perspective all of this would be occurring seamlessly inside mutt. Whether refresh tokens get stored on disk by the script could then be user-configurable, and how they are stored/retrieved could be improved as better options for that become available (for one thing, allowing the script to interact means users could be prompted for a PIN to unlock tokens). Eventually the entire script logic could be moved inside mutt, but perhaps reasonable to wait until many more cloud providers offer OAuth2 and the subtle differences between their implementations have been identified---it is easier for users to play with script variations to experiment connecting to their cloud provider than to mess around with mutt source code. For the time being, the proposed roadmap would keep most of the script separate but merely move the token state inside mutt and allow the script to interact with the user. Would that be hard to implement?
+Configure script's token file encryption
+----------------------------------------
-I hope this is useful to someone. Feedback/comments/questions/improvements are always welcome.
+The script remembers tokens between invocations by keeping them in a
+token file. This file is encrypted. Inside the script are two lines
+ ENCRYPTION_PIPE
+ DECRYPTION_PIPE
+that must be edited to specify your choice of encryption system. A
+popular choice is gpg. To use this:
---Alex
+ - Install gpg. For example, "sudo apt install gpg".
+ - "gpg --gen-key". Answer the questions. Instead of your email
+ address you could choose say "My mutt_oauth2 token store", then
+ choose a passphrase. You will need to produce that same passphrase
+ whenever mutt_oauth2 needs to unlock the token store.
+ - Edit mutt_oauth2.py and put your GPG identity (your email address or
+ whatever you picked above) in the ENCRYPTION_PIPE line.
+ - For the gpg-agent to be able to ask you the unlock passphrase,
+ the environment variable GPG_TTY must be set to the current tty.
+ Typically you would put the following inside your .bashrc or equivalent:
+ export GPG_TTY=$(tty)
+Create an app registration
+--------------------------
+
+Before you can connect the script to an account, you need an
+"app registration" for that service. Cloud entities (like Google and
+Microsoft) and/or the tenant admins (the central technology admins at
+your school or place of work) might be restrictive in who can create
+app registrations, as well as who can subsequently use them. For
+personal/consumer accounts, you can generally create your own
+registration and then use it with a limited number of different personal
+accounts. But for work/school accounts, the tenant admins might approve an
+app registration that you created with a personal/consumer account, or
+might want an official app registration from a developer (the creation of
+which and blessing by the cloud provider might require payment and/or arduous
+review), or might perhaps be willing to roll their own "in-house" registration.
+
+What you ultimately need is the "client_id" (and "client_secret" if
+one was set) for this registration. Those values must be edited into
+the mutt_oauth2.py script. If your work or school environment has a
+knowledge base that provides the client_id, then someone already took
+care of the app registration, and you can skip the step of creating
+your own registration.
+
+
+-- How to create a Google registration --
+
+Go to console.developers.google.com, and create a new project. The name doesn't
+matter and could be "mutt registration project".
+
+ - Go to Library, choose Gmail API, and enable it
+ - Hit left arrow icon to get back to console.developers.google.com
+ - Choose OAuth Consent Screen
+ - Choose Internal for an organizational G Suite
+ - Choose External if that's your only choice
+ - For Application Name, put for example "Mutt"
+ - Under scopes, choose Add scope, scroll all the way down, enable the "https://mail.google.com/" scope
+ - Fill out additional fields (application logo, etc) if you feel like it (will make the consent screen look nicer)
+ - Back at console.developers.google.com, choose Credentials
+ - At top, choose Create Credentials / OAuth2 client iD
+ - Application type is "Desktop app"
+
+Edit the client_id (and client_secret if there is one) into the
+mutt_oauth2.py script.
+
+
+-- How to create a Microsoft registration --
+
+Go to portal.azure.com, log in with a Microsoft account (get a free
+one at outlook.com), then search for "app registration", and add a
+new registration. On the initial form that appears, put a name like
+"Mutt", allow any type of account, and put "http://localhost/" as
+the redirect URI, then more carefully go through each
+screen:
+
+Branding
+ - Leave fields blank or put in reasonable values
+ - For official registration, verify your choice of publisher domain
+Authentication:
+ - Platform "Mobile and desktop"
+ - Redirect URI "http://localhost/"
+ - Any kind of account
+ - Enable public client (allow device code flow)
+API permissions:
+ - Microsoft Graph, Delegated, "offline_access"
+ - Microsoft Graph, Delegated, "IMAP.AccessAsUser.All"
+ - Microsoft Graph, Delegated, "POP.AccessAsUser.All"
+ - Microsoft Graph, Delegated, "SMTP.Send"
+ - Microsoft Graph, Delegated, "User.Read"
+Overview:
+ - Take note of the Application ID (a.k.a. Client ID), you'll need it shortly
+
+End users who aren't able to get to the app registration screen within
+portal.azure.com for their work/school account can temporarily use an
+incognito browser window to create a free outlook.com account and use that
+to create the app registration.
+
+Edit the client_id (and client_secret if there is one) into the
+mutt_oauth2.py script.
+
+
+Running the script manually to authorize tokens
+-----------------------------------------------
+
+Run "mutt_oauth2.py --help" to learn script usage. To obtain the
+initial set of tokens, run the script specifying a name for a
+disposable token storage file, as well as "--authorize", for example
+using this naming scheme:
+
+ mutt_oauth2.py userid@myschool.edu.tokens --verbose --authorize
+
+The script will ask questions and provide some instructions. For the
+flow question:
+
+ - "authcode": you paste a complicated URL into a browser, then
+manually extract a "code" parameter from a subsequent URL in the
+browser address bar and paste that back to the script.
+
+- "localhostauthcode": you again paste the complicated URL into a browser
+but that's it --- the code is automatically extracted from the response
+relying on a localhost redirect and temporarily listening on a localhost
+port. This flow can only be used if the web browser opening the redirect
+URL sits on the same machine as where mutt is running, in other words can not
+be used if you ssh to a remote machine and run mutt on that remote machine
+while your web browser remains on your local machine.
+
+ - "devicecode": you go to a simple URL and just enter a short code.
+
+Your answer here determines the default flow, but on any invocation of
+the script you can override the default with the optional "--authflow"
+parameter. To change the default, delete your token file and start over.
+
+To figure out which flow to use, I suggest trying all three.
+Depending on the OAuth2 provider and how the app registration was
+configured, some flows might not work, so simply trying them is the
+best way to figure out what works and which