Skip to content

Commit

Permalink
learn XOAUTH2 authentication
Browse files Browse the repository at this point in the history
Signed-off-by: François Lamboley <francois.lamboley@happn.com>
Signed-off-by: Nicolas Sebrecht <nicolas.s-dev@laposte.net>
  • Loading branch information
cscorley authored and nicolas33 committed Oct 6, 2015
1 parent ca1ce25 commit f7efaa2
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Bugs, issues and contributions can be requested to both the mailing list or the

* Python v2.7
* Python SQlite (optional while recommended)
* Python json and urllib (used for XOAuth2 authentication)


## Documentation
Expand Down
35 changes: 34 additions & 1 deletion offlineimap.conf
Original file line number Diff line number Diff line change
Expand Up @@ -673,9 +673,42 @@ remoteuser = username
# limitations, if GSSAPI is set, it will be tried first, no matter where it was
# specified in the list.
#
#auth_mechanisms = GSSAPI, CRAM-MD5, PLAIN, LOGIN
#auth_mechanisms = GSSAPI, CRAM-MD5, XOAUTH2, PLAIN, LOGIN


# This option stands in the [Repository RemoteExample] section.
#
# XOAuth2 authentication (for instance, to use with Gmail).
#
# This feature is currently EXPERIMENTAL (tested on Gmail only, but should work
# with type = IMAP for compatible servers).
#
# Mandatory parameters are "oauth2_client_id", "oauth2_client_secret" and
# "oauth2_refresh_token". See below to learn how to get those.
#
# Specify the OAuth2 client id and secret to use for the connection..
# Here's how to register an OAuth2 client for Gmail, as of 10-2-2016:
# - Go to the Google developer console
# https://console.developers.google.com/project
# - Create a new project
# - In API & Auth, select Credentials
# - Setup the OAuth Consent Screen
# - Then add Credentials of type OAuth 2.0 Client ID
# - Choose application type Other; type in a name for your client
# - You now have a client ID and client secret
#
#oauth2_client_id = YOUR_CLIENT_ID
#oauth2_client_secret = YOUR_CLIENT_SECRET

# Specify the refresh token to use for the connection to the mail server.
# Here's an example of a way to get a refresh token:
# - Clone this project: https://github.com/google/gmail-oauth2-tools
# - Type the following command-line in a terminal and follow the instructions
# python python/oauth2.py --generate_oauth2_token \
# --client_id=YOUR_CLIENT_ID --client_secret=YOUR_CLIENT_SECRET
#
#oauth2_refresh_token = REFRESH_TOKEN

########## Passwords

# There are six ways to specify the password for the IMAP server:
Expand Down
43 changes: 42 additions & 1 deletion offlineimap/imapserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
import hmac
import socket
import base64

import json
import urllib

import time
import errno
from sys import exc_info
Expand Down Expand Up @@ -89,6 +93,12 @@ def __init__(self, repos):
self.fingerprint = repos.get_ssl_fingerprint()
self.sslversion = repos.getsslversion()

self.oauth2_refresh_token = repos.getoauth2_refresh_token()
self.oauth2_client_id = repos.getoauth2_client_id()
self.oauth2_client_secret = repos.getoauth2_client_secret()
self.oauth2_request_url = repos.getoauth2_request_url()
self.oauth2_access_token = None

self.delim = None
self.root = None
self.maxconnections = repos.getmaxconnections()
Expand Down Expand Up @@ -199,7 +209,33 @@ def __plainhandler(self, response):
return retval


# XXX: describe function
def __xoauth2handler(self, response):
if self.oauth2_refresh_token is None:
return None

if self.oauth2_access_token is None:
# need to move these to config
# generate new access token
params = {}
params['client_id'] = self.oauth2_client_id
params['client_secret'] = self.oauth2_client_secret
params['refresh_token'] = self.oauth2_refresh_token
params['grant_type'] = 'refresh_token'

self.ui.debug('imap', 'xoauth2handler: url "%s"' % self.oauth2_request_url)
self.ui.debug('imap', 'xoauth2handler: params "%s"' % params)

response = urllib.urlopen(self.oauth2_request_url, urllib.urlencode(params)).read()
resp = json.loads(response)
self.ui.debug('imap', 'xoauth2handler: response "%s"' % resp)
self.oauth2_access_token = resp['access_token']

self.ui.debug('imap', 'xoauth2handler: access_token "%s"' % self.oauth2_access_token)
auth_string = 'user=%s\1auth=Bearer %s\1\1' % (self.username, self.oauth2_access_token)
#auth_string = base64.b64encode(auth_string)
self.ui.debug('imap', 'xoauth2handler: returning "%s"' % auth_string)
return auth_string

def __gssauth(self, response):
data = base64.b64encode(response)
try:
Expand Down Expand Up @@ -283,6 +319,10 @@ def __authn_plain(self, imapobj):
imapobj.authenticate('PLAIN', self.__plainhandler)
return True

def __authn_xoauth2(self, imapobj):
imapobj.authenticate('XOAUTH2', self.__xoauth2handler)
return True

def __authn_login(self, imapobj):
# Use LOGIN command, unless LOGINDISABLED is advertized
# (per RFC 2595)
Expand Down Expand Up @@ -314,6 +354,7 @@ def __authn_helper(self, imapobj):
auth_methods = {
"GSSAPI": (self.__authn_gssapi, False, True),
"CRAM-MD5": (self.__authn_cram_md5, True, True),
"XOAUTH2": (self.__authn_xoauth2, True, True),
"PLAIN": (self.__authn_plain, True, True),
"LOGIN": (self.__authn_login, True, False),
}
Expand Down
14 changes: 14 additions & 0 deletions offlineimap/repository/Gmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class GmailRepository(IMAPRepository):
# Gmail IMAP server port
PORT = 993

OAUTH2_URL = 'https://accounts.google.com/o/oauth2/token'

def __init__(self, reposname, account):
"""Initialize a GmailRepository object."""
# Enforce SSL usage
Expand All @@ -49,6 +51,18 @@ def gethost(self):
self._host = GmailRepository.HOSTNAME
return self._host

def getoauth2_request_url(self):
"""Return the server name to connect to.
Gmail implementation first checks for the usual IMAP settings
and falls back to imap.gmail.com if not specified."""
try:
return super(GmailRepository, self).getoauth2_request_url()
except OfflineImapError:
# nothing was configured, cache and return hardcoded one
self._oauth2_request_url = GmailRepository.OAUTH2_URL
return self._oauth2_request_url

def getport(self):
return GmailRepository.PORT

Expand Down
29 changes: 27 additions & 2 deletions offlineimap/repository/IMAP.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def __init__(self, reposname, account):
BaseRepository.__init__(self, reposname, account)
# self.ui is being set by the BaseRepository
self._host = None
self._oauth2_request_url = None
self.imapserver = imapserver.IMAPServer(self)
self.folders = None
if self.getconf('sep', None):
Expand Down Expand Up @@ -125,12 +126,12 @@ def get_remote_identity(self):
return self.getconf('remote_identity', default=None)

def get_auth_mechanisms(self):
supported = ["GSSAPI", "CRAM-MD5", "PLAIN", "LOGIN"]
supported = ["GSSAPI", "XOAUTH2", "CRAM-MD5", "PLAIN", "LOGIN"]
# Mechanisms are ranged from the strongest to the
# weakest ones.
# TODO: we need DIGEST-MD5, it must come before CRAM-MD5
# TODO: due to the chosen-plaintext resistance.
default = ["GSSAPI", "CRAM-MD5", "PLAIN", "LOGIN"]
default = ["GSSAPI", "XOAUTH2", "CRAM-MD5", "PLAIN", "LOGIN"]

mechs = self.getconflist('auth_mechanisms', r',\s*',
default)
Expand Down Expand Up @@ -252,6 +253,30 @@ def get_ssl_fingerprint(self):
value = self.getconf('cert_fingerprint', "")
return [f.strip().lower() for f in value.split(',') if f]

def getoauth2_request_url(self):
if self._oauth2_request_url: # use cached value if possible
return self._oauth2_request_url

oauth2_request_url = self.getconf('oauth2_request_url', None)
if oauth2_request_url != None:
self._oauth2_request_url = oauth2_request_url
return self._oauth2_request_url

# no success
raise OfflineImapError("No remote oauth2_request_url for repository "\
"'%s' specified." % self,
OfflineImapError.ERROR.REPO)
return self.getconf('oauth2_request_url', None)

def getoauth2_refresh_token(self):
return self.getconf('oauth2_refresh_token', None)

def getoauth2_client_id(self):
return self.getconf('oauth2_client_id', None)

def getoauth2_client_secret(self):
return self.getconf('oauth2_client_secret', None)

def getpreauthtunnel(self):
return self.getconf('preauthtunnel', None)

Expand Down

0 comments on commit f7efaa2

Please sign in to comment.