> On Jun 14, 2020, at 8:54 PM, Kevin J. McCarthy <ke...@8t8.us> wrote:
> 
> I made a commit to master adding this yesterday.  I did some testing on 
> Gmail, but haven't gotten around to testing Microsoft platforms yet.  I could 
> definitely use some help there.

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.

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.

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:

  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

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 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:

  mutt_oauth2.py use...@myschool.edu.tokens --verbose --authorize

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?).

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_oauth2.py use...@myschool.edu.tokens --verbose --test
  mutt_oauth2.py use...@myschool.edu.tokens --verbose --debug --test

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:

  set imap_user="use...@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}

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.

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?


I hope this is useful to someone. Feedback/comments/questions/improvements are 
always welcome.

--Alex

#!/usr/bin/python3
#
# Mutt OAuth2 token management script, version 2020-07-01
#
#   Copyright (C) 2020 Alexander Perlis
#
#   This program is free software; you can redistribute it and/or
#   modify it under the terms of the GNU General Public License as
#   published by the Free Software Foundation; either version 2 of the
#   License, or (at your option) any later version.
#
#   This program is distributed in the hope that it will be useful,
#   but WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#   General Public License for more details.
#
#   You should have received a copy of the GNU General Public License
#   along with this program; if not, write to the Free Software
#   Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
#   02110-1301, USA.

import sys
import json
import argparse
import urllib.parse
import urllib.request
import imaplib
import poplib
import smtplib
import base64
import secrets
import hashlib
import time
from datetime import timedelta
from datetime import datetime
from pathlib import Path

registrations = {
    'google': {
        'authorize_endpoint': 'https://accounts.google.com/o/oauth2/auth',
        'devicecode_endpoint': 'https://oauth2.googleapis.com/device/code',
        'token_endpoint': 'https://accounts.google.com/o/oauth2/token',
        'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob',
        'imap_endpoint': 'imap.gmail.com',
        'pop_endpoint': 'pop.gmail.com',
        'smtp_endpoint': 'smtp.gmail.com',
        'sasl_method': 'OAUTHBEARER',
        'scope': 'https://mail.google.com/',
        'client_id': '',
        'client_secret': '',
    },
    'microsoft': {
        'authorize_endpoint': 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
        'devicecode_endpoint': 'https://login.microsoftonline.com/common/oauth2/v2.0/devicecode',
        'token_endpoint': 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
        'redirect_uri': 'https://login.microsoftonline.com/common/oauth2/nativeclient',
        'tenant': 'common',
        'imap_endpoint': 'outlook.office365.com',
        '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',
        'client_id': '',
        'client_secret': '',
    },
}

ap = argparse.ArgumentParser(epilog='''
Run with "--verbose --authorize" to get started or whenever all tokens have expired.
To truly start over from scratch, first delete TOKENFILE. Use "--verbose --test" to
test the IMAP/POP/SMTP endpoints. Programs like mutt that just need an access token
should call this script without optional parameters.''')
ap.add_argument('-v', '--verbose', action='store_true', help='increase verbosity')
ap.add_argument('-d', '--debug', action='store_true', help='enable debug output')
ap.add_argument('tokenfile', help='persistent token storage')
ap.add_argument('-a', '--authorize', action='store_true', help='manually authorize new refresh token')
ap.add_argument('-t', '--test', action='store_true', help='test IMAP/POP/SMTP endpoints')
args = ap.parse_args()

f = Path(args.tokenfile)
if not f.exists(): f.touch(mode=0o600)
if 0o600 != 0o777 & f.stat().st_mode:
    print('Token file has unsafe mode. Suggest deleting and starting over.')
    sys.exit(1)
token = {}
with open(args.tokenfile, 'r') as f:
    try: token = json.load(f)
    except json.JSONDecodeError: pass
if args.debug: print('Obtained from token file:', json.dumps(token))
if not token:
    if args.verbose: print('NOTICE: Empty or malformed token file.')
    if not args.authorize:
        print('You must run script with "--authorize" at least once.')
        sys.exit(1)
    print('Available app and endpoint registrations:', *registrations)
    token['registration'] = input('OAuth2 registration: ')
    token['oauthflow'] = input('Preferred OAuth2 flow ("authcode" or "devicecode"): ')
    token['email'] = input('Account e-mail address: ')
    token['access_token'] = ''
    token['access_token_expiration'] = ''
    token['refresh_token'] = ''
    with open(args.tokenfile, 'w') as f: json.dump(token, f)

if token['registration'] not in registrations:
    print(f'ERROR: Unknown registration "{token["registration"]}". Delete token file and start over.')
    sys.exit(1)

registration = registrations[token['registration']]
baseparams = {'client_id': registration['client_id']}
# Microsoft uses 'tenant' but Google does not
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)

def update_tokens(r):
    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']
    with open(args.tokenfile, 'w') as f: json.dump(token, f)
    if args.verbose: print(f'NOTICE: Obtained new access token, expires {token["access_token_expiration"]}.')


if args.authorize:
    p = baseparams.copy();
    p['scope'] = registration['scope']

    if token['oauthflow'] == 'authcode':
        verifier = secrets.token_urlsafe(90)
        challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())[:-1]
        p.update({'login_hint': token['email'],
                  'response_type': 'code',
                  'redirect_uri': registration['redirect_uri'],
                  'code_challenge': challenge,
                  'code_challenge_method': 'S256'})
        print(registration["authorize_endpoint"]+'?'+urllib.parse.urlencode(p, quote_via=urllib.parse.quote))
        authcode = input('Visit displayed URL to retrieve authorization code. Enter code from server (might be in browser address bar): ')
        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())
        except urllib.error.HTTPError as err:
            print(err.code, err.reason)
            response = err
        response = response.read()
        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'])
            sys.exit(1)

    elif token['oauthflow'] == 'devicecode':
        try:
            response = urllib.request.urlopen(registration['devicecode_endpoint'],
                                      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)
        response = json.loads(response)
        if 'error' in response:
            print(response['error'])
            if 'error_description' in response: print(response['error_description'])
            sys.exit(1)
        print(response['message'])
        del p['scope']
        p.update({'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
                  'client_secret': registration['client_secret'],
                  'device_code': response['device_code']})
        interval = int(response['interval'])
        print('Polling...', end='', flush=True)
        while True:
            time.sleep(interval)
            print('.', end='', flush=True)
            try:
                response = urllib.request.urlopen(registration['token_endpoint'],
                                      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)
            response = json.loads(response)
            if 'error' not in response: break
            if response['error'] == 'authorization_declined':
                print(' user declined authorization.')
                sys.exit(1)
            if response['error'] == 'expired_token':
                print(' too much time has elapsed.')
                sys.exit(1)
            if response['error'] != 'authorization_pending':
                print(response['error'])
                if 'error_description' in response: print(response['error_description'])
                sys.exit(1)
        print()

    else:
        print(f'ERROR: Unknown OAuth2 flow "{token["oauthflow"]}. Delete token file and start over.')
        sys.exit(1)

    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 not token['refresh_token']:
        print(f'ERROR: No refresh token. Manually run "{sys.argv[0]} tokenfile --authorize".')
        sys.exit(1)
    p = baseparams.copy()
    p.update({'client_secret': registration['client_secret'],
              'refresh_token': token['refresh_token'],
              'grant_type': 'refresh_token'})
    try:
        response = urllib.request.urlopen(registration['token_endpoint'],
                                      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)
    response = json.loads(response)
    if 'error' in response:
        print(response['error'])
        if 'error_description' in response: print(response['error_description'])
        print(f'Perhaps refresh token invalid. Try starting over with "{sys.argv[0]} tokenfile --authorize"')
        sys.exit(1)
    update_tokens(response)


if not access_token_valid():
    print('ERROR: No valid access token. This should not be able to happen.')
    sys.exit(1)


if args.verbose: print('Access Token: ', end='')
print(token['access_token'])
    

def build_sasl_string(user,host,port,token):
    if registration['sasl_method'] == 'OAUTHBEARER':
        return f'n,a={user},\1host={host}\1port={port}\1auth=Bearer {token}\1\1'
    elif registration['sasl_method'] == 'XOAUTH2':
        return f'user={user}\1auth=Bearer {token}\1\1'
    else: print(f'Unknown SASL method {registration["sasl_method"]}.'); sys.exit(1)

    
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
    try:
        imap_conn.authenticate(registration['sasl_method'], lambda _: sasl_string.encode())
        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)
    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')
    except poplib.error_proto as e:
        print('POP authentication FAILED (does your account allow POP?):', e)
        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'])
    smtp_conn.ehlo('test')
    smtp_conn.starttls()
    smtp_conn.ehlo('test')
    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')
    except smtplib.SMTPAuthenticationError as e:
        print('SMTP authentication FAILED:', e)
        errors = True

    if errors: sys.exit(1)

Reply via email to