From 479e39fbb7393dcca7d3a475a09128b8c7ea7a43 Mon Sep 17 00:00:00 2001
From: Randy Ramos <rramos1295@gmail.com>
Date: Mon, 4 Sep 2017 19:20:16 -0400
Subject: Add OAuth2 support for calcurse-caldav

OAuth2 authentication is completely optional. It is controlled by the
AuthMethod option in the config file. Other required options were
appended to config.sample.

Signed-off-by: Randy Ramos <rramos1295@gmail.com>
Signed-off-by: Lukas Fleischer <lfleischer@calcurse.org>
---
 contrib/caldav/calcurse-caldav.py | 108 ++++++++++++++++++++++++++++++++++++++
 contrib/caldav/config.sample      |  16 ++++++
 2 files changed, 124 insertions(+)

(limited to 'contrib')

diff --git a/contrib/caldav/calcurse-caldav.py b/contrib/caldav/calcurse-caldav.py
index 3725068..219ce34 100755
--- a/contrib/caldav/calcurse-caldav.py
+++ b/contrib/caldav/calcurse-caldav.py
@@ -12,6 +12,14 @@ import textwrap
 import urllib.parse
 import xml.etree.ElementTree as etree
 
+# Optional libraries for OAuth2 authentication
+try:
+    from oauth2client.client import OAuth2WebServerFlow, HttpAccessTokenRefreshError
+    from oauth2client.file import Storage
+    import webbrowser
+except ModuleNotFoundError:
+    pass
+
 
 def msgfmt(msg, prefix=''):
     lines = []
@@ -92,6 +100,65 @@ def get_auth_headers():
     return headers
 
 
+def init_auth(client_id, client_secret, scope, redirect_uri, authcode):
+    # Create OAuth2 session
+    oauth2_client = OAuth2WebServerFlow(client_id=client_id,
+                                        client_secret=client_secret,
+                                        scope=scope,
+                                        redirect_uri=redirect_uri)
+
+    # If auth code is missing, tell user run script with auth code
+    if not authcode:
+        # Generate and open URL for user to authorize
+        auth_uri = oauth2_client.step1_get_authorize_url()
+        webbrowser.open(auth_uri)
+
+        prompt = ('\nIf a browser window did not open, go to the URL '
+                  'below and log in to authorize syncing. '
+                  'Once authorized, pass the string after "code=" from '
+                  'the URL in your browser\'s address bar to '
+                  'calcurse-caldav.py using the "--authcode" flag. '
+                  "Example: calcurse-caldav --authcode "
+                  "'your_auth_code_here'\n\n{}\n".format(auth_uri))
+        print(prompt)
+        die("Access token is missing or refresh token is expired.")
+
+    # Create and return Credential object from auth code
+    credentials = oauth2_client.step2_exchange(authcode)
+
+    # Setup storage file and store credentials
+    storage = Storage(oauth_file)
+    credentials.set_store(storage)
+    storage.put(credentials)
+
+    return credentials
+
+
+def run_auth(authcode):
+    # Check if credentials file exists
+    if os.path.isfile(oauth_file):
+
+        # Retrieve token from file
+        storage = Storage(oauth_file)
+        credentials = storage.get()
+
+        # Set file to store it in for future functions
+        credentials.set_store(storage)
+
+        # Refresh the access token if it is expired
+        if credentials.invalid:
+            try:
+                credentials.refresh(httplib2.Http())
+            except HttpAccessTokenRefreshError:
+                # Initialize OAuth2 again if refresh token becomes invalid
+                credentials = init_auth(client_id, client_secret, scope, redirect_uri, authcode)
+    else:
+        # Initialize OAuth2 credentials
+        credentials = init_auth(client_id, client_secret, scope, redirect_uri, authcode)
+
+    return credentials
+
+
 def remote_query(conn, cmd, path, additional_headers, body):
     headers = custom_headers.copy()
     headers.update(get_auth_headers())
@@ -384,6 +451,7 @@ configfn = os.path.expanduser("~/.calcurse/caldav/config")
 lockfn = os.path.expanduser("~/.calcurse/caldav/lock")
 syncdbfn = os.path.expanduser("~/.calcurse/caldav/sync.db")
 hookdir = os.path.expanduser("~/.calcurse/caldav/hooks/")
+oauth_file = os.path.expanduser("~/.calcurse/caldav/oauth2_cred")
 
 # Parse command line arguments.
 parser = argparse.ArgumentParser('calcurse-caldav')
@@ -402,6 +470,9 @@ parser.add_argument('--syncdb', action='store', dest='syncdbfn',
 parser.add_argument('--hookdir', action='store', dest='hookdir',
                     default=hookdir,
                     help='path to the calcurse-caldav hooks directory')
+parser.add_argument('--authcode', action='store', dest='authcode',
+                    default=None,
+                    help='auth code for OAuth2 authentication')
 parser.add_argument('-v', '--verbose', action='store_true', dest='verbose',
                     default=False,
                     help='print status messages to stdout')
@@ -414,6 +485,7 @@ configfn = args.configfn
 lockfn = args.lockfn
 syncdbfn = args.syncdbfn
 hookdir = args.hookdir
+authcode = args.authcode
 verbose = args.verbose
 debug = args.debug
 
@@ -452,6 +524,11 @@ if not verbose and config.has_option('General', 'Verbose'):
 if not debug and config.has_option('General', 'Debug'):
     debug = config.getboolean('General', 'Debug')
 
+if config.has_option('General', 'AuthMethod'):
+    authmethod = config.get('General', 'AuthMethod').lower()
+else:
+    authmethod = 'basic'
+
 if config.has_option('Auth', 'UserName'):
     username = config.get('Auth', 'UserName')
 else:
@@ -467,6 +544,26 @@ if config.has_section('CustomHeaders'):
 else:
     custom_headers = {}
 
+if config.has_option('OAuth2', 'ClientID'):
+    client_id = config.get('OAuth2', 'ClientID')
+else:
+    client_id = None
+
+if config.has_option('OAuth2', 'ClientSecret'):
+    client_secret = config.get('OAuth2', 'ClientSecret')
+else:
+    client_secret = None
+
+if config.has_option('OAuth2', 'Scope'):
+   scope = config.get('OAuth2', 'Scope')
+else:
+   scope = None
+
+if config.has_option('OAuth2', 'RedirectURI'):
+    redirect_uri = config.get('OAuth2', 'RedirectURI')
+else:
+    redirect_uri = 'http://127.0.0.1'
+
 # Show disclaimer when performing a dry run.
 if dry_run:
     warn(('Dry run; nothing is imported/exported. Add "DryRun = No" to the '
@@ -500,6 +597,13 @@ try:
     if insecure_ssl:
         conn.disable_ssl_certificate_validation = True
 
+    if authmethod == 'oauth2':
+        # Authenticate with OAuth2 and authorize HTTP object
+        cred = run_auth(authcode)
+        conn = cred.authorize(conn)
+    elif authmethod != 'basic':
+        die('Invalid option for AuthMethod in config file. Use "basic" or "oauth2"')
+
     if init:
         # In initialization mode, start with an empty synchronization database.
         if args.init == 'keep-remote':
@@ -551,6 +655,10 @@ try:
     # Write the synchronization database.
     save_syncdb(syncdbfn, syncdb)
 
+    #Clear OAuth2 credentials if used
+    if authmethod == 'oauth2':
+        conn.clear_credentials()
+
 finally:
     # Remove lock file.
     os.remove(lockfn)
diff --git a/contrib/caldav/config.sample b/contrib/caldav/config.sample
index 9b21b2b..76c3ce3 100644
--- a/contrib/caldav/config.sample
+++ b/contrib/caldav/config.sample
@@ -12,6 +12,9 @@ Hostname = some.hostname.com
 # Path to the CalDAV calendar on the host specified above.
 Path = /path/to/calendar/on/the/server/
 
+# Type of authentication to use. Must be "basic" or "oauth2"
+#AuthMethod = basic
+
 # Enable this if you want to skip SSL certificate checks.
 InsecureSSL = No
 
@@ -33,3 +36,16 @@ Verbose = Yes
 # Optionally specify additional HTTP headers here.
 #[CustomHeaders]
 #User-Agent = Mac_OS_X/10.9.2 (13C64) CalendarAgent/176
+
+# Use the following to synchronize with an OAuth2-based service
+# such as Google Calendar.
+#[OAuth2]
+#ClientID = your_client_id
+#ClientSecret = your_client_secret
+
+# Scope of access for API calls. Synchronization requires read/write.
+#Scope = https://example.com/resource/scope
+
+# Change the redirect URI if you receive errors, but ensure that it is identical
+# to the redirect URI you specified in the API settings.
+#RedirectURI = http://127.0.0.1
-- 
cgit v1.2.3-70-g09d2