From f172e454604aa06e208c3e7f1763e8bc7c0217ab Mon Sep 17 00:00:00 2001 From: Levi Olson Date: Thu, 14 Oct 2021 18:00:47 -0500 Subject: [PATCH] Woohoo - a working calendar fetch from M$ graph and conversion to Orgmode! --- .gitignore | 4 + .python-version | 1 + config-example.cfg | 11 +++ config.py | 46 ++++++++++ msgraph-orgmode.py | 90 +++++++++++++++++++ oauth2ms.py | 216 +++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 14 +++ 7 files changed, 382 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 config-example.cfg create mode 100644 config.py create mode 100644 msgraph-orgmode.py create mode 100644 oauth2ms.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..198c1bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.projectile +/__pycache__/ +/venv/ +/config.cfg diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..30291cb --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10.0 diff --git a/config-example.cfg b/config-example.cfg new file mode 100644 index 0000000..a7788b2 --- /dev/null +++ b/config-example.cfg @@ -0,0 +1,11 @@ +[msgraph-orgmode] +client_id= +# Machine, and Login are used to get the correct entry from ~/.authinfo.gpg +machine= +login= +authority_url=https://login.microsoftonline.com/common +resource=https://graph.microsoft.com +api_version=1.0 +days_history=7 +days_future=30 +max_entries=200 diff --git a/config.py b/config.py new file mode 100644 index 0000000..d200abf --- /dev/null +++ b/config.py @@ -0,0 +1,46 @@ +#!/usr/bin/python +"""Configuration settings for console app using device flow authentication +""" + +import re +import os +import configparser + + +# Read Config from File +config = configparser.RawConfigParser({ + 'machine': '', + 'login': '', + 'days_history': 7, + 'days_future': 30, + 'max_entries': 100 +}) +directory = os.path.dirname(os.path.realpath(__file__)) +config.read(os.path.join(directory, 'config.cfg')) + + +def get_secret(machine, login, key): + s = "^machine %s login %s[\^]%s password (.*)$" % (machine, login, key) + p = re.compile(s, re.MULTILINE) + authinfo = os.popen("gpg -q --no-tty -d ~/.authinfo.gpg").read() + matches = p.search(authinfo) + if matches is not None: + return matches.group(1) + else: + return None + +RESOURCE = config.get('msgraph-orgmode', 'resource') +API_VERSION = config.get('msgraph-orgmode', 'api_version') +LOGIN = config.get('msgraph-orgmode', 'login') +MACHINE = config.get('msgraph-orgmode', 'machine') +CLIENT_ID = config.get('msgraph-orgmode', 'client_id') +CLIENT_SECRET = get_secret(MACHINE, LOGIN, 'client_secret') + +daysHistory = config.getint('msgraph-orgmode', 'days_history') +daysFuture = config.getint('msgraph-orgmode', 'days_future') +maxEntries = config.getint('msgraph-orgmode', 'max_entries') + +if not CLIENT_ID and not CLIENT_SECRET: + print('ERROR: CLIENT_ID and CLIENT_SECRET are required.') + import sys + sys.exit(1) diff --git a/msgraph-orgmode.py b/msgraph-orgmode.py new file mode 100644 index 0000000..72d5ac7 --- /dev/null +++ b/msgraph-orgmode.py @@ -0,0 +1,90 @@ +import pyperclip +import mimetypes +import os +import urllib + +from adal import AuthenticationContext + +import requests +import config + +from datetime import datetime +from datetime import date +from datetime import timedelta +import pytz + +import oauth2ms + + +def api_endpoint(url): + """Convert a relative path such as /me/photo/$value to a full URI based + on the current RESOURCE and API_VERSION settings in config.py. + """ + if urllib.parse.urlparse(url).scheme in ['http', 'https']: + return url # url is already complete + return urllib.parse.urljoin(f'{config.RESOURCE}/v{config.API_VERSION}/', + url.lstrip('/')) + + +def parse_cal_date(dateStr): + d = datetime.strptime(dateStr, "%Y-%m-%dT%H:%M:%S.0000000") + exchangeTz = pytz.utc + localTz = pytz.timezone('US/Central') + return exchangeTz.localize(d).astimezone(localTz); + + +def format_orgmode_date(dateObj): + return dateObj.strftime("%Y-%m-%d %H:%M") + + +def format_orgmode_time(dateObj): + return dateObj.strftime("%H:%M") + + +def get_calendar(token): + """Get Calendar from O365 + """ + start = date.today() - timedelta(days=config.daysHistory) + end = date.today() + timedelta(days=config.daysFuture) + # print(api_endpoint("/")) + cal = requests.get( + api_endpoint(f'me/calendarview?startdatetime={start}T00:00:00.000Z&enddatetime={end}T23:59:59.999Z&$top={config.maxEntries}&$orderby=start/dateTime'), + headers={'Authorization':f'Bearer {token}'} + ) + + calentries = cal.json() + + for appt in calentries['value']: + apptstart = parse_cal_date(appt['start']['dateTime']) + apptend = parse_cal_date(appt['end']['dateTime']) + tags = "" + if appt['categories']: + tags = ":" + ":".join(appt['categories']) + ":" + else: + tags = ":WORK:" + + if apptstart.date() == apptend.date(): + dateStr = "<" + format_orgmode_date(apptstart) + "-" + format_orgmode_time(apptend) + ">" + else: + dateStr = "<" + format_orgmode_date(apptstart) + ">--<" + format_orgmode_date(apptend) + ">" + body = appt['bodyPreview'].translate({ord('\r'): None}) + + print(f'* {dateStr} {appt["subject"]} {tags}') + + print(":PROPERTIES:") + if appt['location']['displayName'] is not None: + print(":LOCATION: %s" % (appt['location']['displayName'])) + + if appt['onlineMeeting'] is not None: + print(f":JOINURL: {appt['onlineMeeting']['joinUrl']}") + + print(f":RESPONSE: {appt['responseStatus']['response']}") + print(":END:") + + print(f"{body}") + + print("") + + +if __name__ == '__main__': + get_calendar(oauth2ms.token) diff --git a/oauth2ms.py b/oauth2ms.py new file mode 100644 index 0000000..4b8936b --- /dev/null +++ b/oauth2ms.py @@ -0,0 +1,216 @@ +# Copyright 2020 Harish Krupo +# 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. + +from xdg.BaseDirectory import ( + load_first_config, + save_data_path + ) + +import argparse +import webbrowser +import logging +import json +import msal + +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs, urlencode +from wsgiref import simple_server +import wsgiref.util +import sys +import uuid +import pprint +import os +import atexit +import base64 +import gnupg +import io + +pp = pprint.PrettyPrinter(indent=4) + +credentials_file = save_data_path("oauth2ms") + "/credentials.bin" + +_LOGGER = logging.getLogger(__name__) + +APP_NAME = "oauth2ms" +SUCCESS_MESSAGE = "Authorization complete." + + +def load_config(): + config_file = load_first_config(APP_NAME, "config.json") + if config_file is None: + print(f"Couldn't find configuration file. Config file must be at: $XDG_CONFIG_HOME/{APP_NAME}/config.json") + print("Current value of $XDG_CONFIG_HOME is {}".format(os.getenv("XDG_CONFIG_HOME"))) + return None + return json.load(open(config_file, 'r')) + +def build_msal_app(config, cache = None): + return msal.ConfidentialClientApplication( + config['client_id'], + authority="https://login.microsoftonline.com/" + config['tenant_id'], + client_credential=config['client_secret'], token_cache=cache) + +def get_auth_url(config, cache, state): + return build_msal_app(config, cache).get_authorization_request_url( + config['scopes'], + state=state, + redirect_uri=config["redirect_uri"]); + +class WSGIRequestHandler(wsgiref.simple_server.WSGIRequestHandler): + """Silence out the messages from the http server""" + def log_message(self, format, *args): + pass + +class WSGIRedirectionApp(object): + """WSGI app to handle the authorization redirect. + + Stores the request URI and displays the given success message. + """ + + def __init__(self, message): + self.last_request_uri = None + self._success_message = message + + def __call__(self, environ, start_response): + start_response("200 OK", [("Content-type", "text/plain; charset=utf-8")]) + self.last_request_uri = wsgiref.util.request_uri(environ) + return [self._success_message.encode("utf-8")] + +def validate_config(config): + conditionsBoth = [ + "tenant_id" in config, + "client_id" in config, + "scopes" in config, + ] + + conditionsAuth = [ + "redirect_host" in config, + "redirect_port" in config, + "redirect_path" in config, + "client_secret" in config + ] + + if not all(conditionsBoth): + print("Invalid config") + print("Config must contain the keys: " + + "tenant_id, client_id, scopes"); + return False + else: + return True + + +def build_new_app_state(): + cache = msal.SerializableTokenCache() + config = load_config() + if config is None: + return None + + if not validate_config(config): + return None + + + state = str(uuid.uuid4()) + redirect_path = config["redirect_path"] + # Remove / at the end if present, and path is not just / + if redirect_path != "/" and redirect_path[-1] == "/": + redirect_path = redirect_path[:-1] + + config["redirect_uri"] = "http://" + config['redirect_host'] + ":" + config['redirect_port'] + redirect_path + auth_url = get_auth_url(config, cache, state) + wsgi_app = WSGIRedirectionApp(SUCCESS_MESSAGE) + http_server = simple_server.make_server(config['redirect_host'], + int(config['redirect_port']), + wsgi_app, + handler_class=WSGIRequestHandler) + + webbrowser.open(auth_url, new=2, autoraise=True) + + http_server.handle_request() + + auth_response = wsgi_app.last_request_uri + http_server.server_close() + parsed_auth_response = parse_qs(auth_response) + + code_key = config["redirect_uri"] + "?code" + if code_key not in parsed_auth_response: + return None + + auth_code = parsed_auth_response[code_key] + + result = build_msal_app(config, cache).acquire_token_by_authorization_code( + auth_code, + scopes=config['scopes'], + redirect_uri=config["redirect_uri"]); + + if result.get("access_token") is None: + print("Something went wrong during authorization") + print(f"Server returned: {result}") + return None + + token = result["access_token"] + app = {} + app['config'] = config + app['cache'] = cache + return app, token + +def fetch_token_from_cache(app): + config = app['config'] + cache = app['cache'] + cca = build_msal_app(config, cache) + accounts = cca.get_accounts() + if cca.get_accounts: + result = cca.acquire_token_silent(config['scopes'], account=accounts[0]) + return result["access_token"] + else: + None + +def build_app_state_from_credentials(): + # If the file is missing return None + if not os.path.exists(credentials_file): + return None + + credentials = open(credentials_file, "r").read() + + # Make sure it is a valid json object + try: + config = json.loads(credentials) + except: + print("Not a valild json file or it is ecrypted. Maybe add/remove the -e arugment?") + sys.exit(1) + + # We don't have a token cache? + if config.get("token_cache") is None: + return None + + app_state = {}; + cache = msal.SerializableTokenCache() + cache.deserialize(config["token_cache"]) + app_state["config"] = config + app_state["cache"] = cache + return app_state + +token = None +app_state = build_app_state_from_credentials() +if app_state is None: + app_state, token = build_new_app_state() + +if app_state is None: + print("Something went wrong!") + sys.exit(1) + +if token is None: + token = fetch_token_from_cache(app_state) + +cache = app_state['cache'] +if cache.has_state_changed: + config = app_state["config"] + config["token_cache"] = cache.serialize() + config_json = json.dumps(config) + open(credentials_file, "w").write(config_json) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3e3e47f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +adal==1.2.7 +certifi==2021.10.8 +cffi==1.15.0 +charset-normalizer==2.0.7 +configparser==5.0.2 +cryptography==35.0.0 +idna==3.3 +pycparser==2.20 +PyJWT==2.2.0 +python-dateutil==2.8.2 +pytz==2021.3 +requests==2.26.0 +six==1.16.0 +urllib3==1.26.7