diff --git a/.gitignore b/.gitignore index b6e4761..5f0776c 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ db.sqlite3-journal # Flask stuff: instance/ .webassets-cache +config.py # Scrapy stuff: .scrapy diff --git a/app.py b/app.py index 41036d2..b4c3602 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,15 @@ import json -from flask import Flask, Response, request +from flask import Flask, Response, redirect, request, session, url_for +from flask_oauthlib.contrib.client import OAuth app = Flask(__name__) +app.config.from_pyfile('config.py', silent=True) + +if app.config['ENV'] != 'production': + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + @app.before_first_request def load_plugins(): print('load the plugins once here?') @@ -12,24 +18,76 @@ def load_plugins(): def status(): return Response(json.dumps({"ok": True}), status=200, mimetype='application/json') -@app.route('/v1/commands') -def list_commands(): - def describe_command(plugin_name, command_name, command): - parameters = PluginService.command_params_desc(command.__init__) - plugin_display_name = PluginService.plugin_display_name(plugin_name) - command_id = PluginService.command_id(plugin_name, command_name) - return { 'id': command_id, 'parameters': parameters } - - commands_by_plugin = PluginService.available_commands_by_plugin() +def list_targets(targets): descriptions = [] - for plugin_name, commands in commands_by_plugin.items(): - for command_name, command in commands.items(): - description = describe_command(plugin_name, command_name, command) + for plugin_name, plugin_targets in targets.items(): + for target_name, target in plugin_targets.items(): + description = PluginService.describe_target(plugin_name, target_name, target) descriptions.append(description) return Response(json.dumps(descriptions), status=200, mimetype='application/json') +@app.route('/v1/auths') +def list_auths(): + return list_targets(PluginService.available_auths_by_plugin()) + +@app.route('/v1/commands') +def list_commands(): + return list_targets(PluginService.available_commands_by_plugin()) + +def auth_handler(plugin_display_name, auth_name, params): + auth = PluginService.auth_named(plugin_display_name, auth_name) + if auth is not None: + handler_params = auth.filtered_params(params) + app_description = auth(**handler_params).app_description() + + # TODO right now this assumes Oauth. + # would need to expand if other auth providers are used + handler = OAuth(app).remote_app(**app_description) + + @handler.tokengetter + def tokengetter(): + pass + + @handler.tokensaver + def tokensaver(token): + pass + + return handler + +@app.route('/v1/auth//') +def do_auth(plugin_display_name, auth_name): + params = request.args.to_dict() + our_redirect_url = params['redirect_url'] + session['redirect_url'] = our_redirect_url + + handler = auth_handler(plugin_display_name, auth_name, params) + if handler is None: + return Response('Auth not found', status=404) + + # TODO factor into handler + # TODO namespace the keys + session['client_id'] = params['client_id'] + session['client_secret'] = params['client_secret'] + + oauth_redirect_url = url_for('auth_callback', plugin_display_name=plugin_display_name, auth_name=auth_name, _external=True) + + return handler.authorize(callback_uri=oauth_redirect_url) + +@app.route('/v1/auth///callback') +def auth_callback(plugin_display_name, auth_name): + handler = auth_handler(plugin_display_name, auth_name, session) + if handler is None: + return Response('Auth not found', status=404) + + response = json.dumps(handler.authorized_response()) + redirect_url = session['redirect_url'] + + # TODO compare redirect_url to whitelist + + return redirect(f'{redirect_url}?response={response}') + @app.route('/v1/do//') def do_command(plugin_display_name, command_name): command = PluginService.command_named(plugin_display_name, command_name) @@ -69,21 +127,43 @@ class PluginService: } @staticmethod - def available_commands_by_plugin(): + def available_auths_by_plugin(): return { plugin_name: { - command_name: command - for command_name, command - in PluginService.commands_for_plugin(plugin_name, plugin) + auth_name: auth + for auth_name, auth + in PluginService.auths_for_plugin(plugin_name, plugin) } - for plugin_name, plugin - in PluginService.available_plugins().items() + for plugin_name, plugin + in PluginService.available_plugins().items() } @staticmethod - def command_id(plugin_name, command_name): + def available_commands_by_plugin(): + return { + plugin_name: { + command_name: command + for command_name, command + in PluginService.commands_for_plugin(plugin_name, plugin) + } + for plugin_name, plugin + in PluginService.available_plugins().items() + } + + @staticmethod + def target_id(plugin_name, target_name): plugin_display_name = PluginService.plugin_display_name(plugin_name) - return f'{plugin_display_name}/{command_name}' + return f'{plugin_display_name}/{target_name}' + + @staticmethod + def auth_named(plugin_display_name, auth_name): + plugin_name = PluginService.plugin_name_from_display_name(plugin_display_name) + available_auths_by_plugin = PluginService.available_auths_by_plugin() + + try: + return available_auths_by_plugin[plugin_name][auth_name] + except: + return None @staticmethod def command_named(plugin_display_name, command_name): @@ -96,11 +176,11 @@ class PluginService: return None @staticmethod - def modules_for_plugin(plugin): + def modules_for_plugin_in_package(plugin, package_name): for finder, name, ispkg in pkgutil.iter_modules(plugin.__path__): - if ispkg and name.startswith(PluginService.PLUGIN_PREFIX): + if ispkg and name == package_name: sub_pkg = finder.find_module(name).load_module(name) - yield from PluginService.modules_for_plugin(sub_pkg) + yield from PluginService.modules_for_plugin_in_package(sub_pkg, None) else: spec = finder.find_spec(name) if spec is not None and spec.loader is not None: @@ -109,13 +189,21 @@ class PluginService: yield name, module @staticmethod - def commands_for_plugin(plugin_name, plugin): - for module_name, module in PluginService.modules_for_plugin(plugin): + def targets_for_plugin(plugin_name, plugin, target_package_name): + for module_name, module in PluginService.modules_for_plugin_in_package(plugin, target_package_name): for member_name, member in inspect.getmembers(module, inspect.isclass): if member.__module__ == module_name: - # TODO check if class has an execute method before yielding yield member_name, member + @staticmethod + def auths_for_plugin(plugin_name, plugin): + yield from PluginService.targets_for_plugin(plugin_name, plugin, 'auths') + + @staticmethod + def commands_for_plugin(plugin_name, plugin): + # TODO check if class has an execute method before yielding + yield from PluginService.targets_for_plugin(plugin_name, plugin, 'commands') + @staticmethod def param_annotation_desc(param): """Parses a callable parameter's type annotation, if any, to form a ParameterDescription.""" @@ -159,8 +247,8 @@ class PluginService: return {"id": param_id, "type": param_type_desc, "required": param_req} @staticmethod - def command_params_desc(command): - sig = inspect.signature(command) + def callable_params_desc(kallable): + sig = inspect.signature(kallable) params_to_skip = ['self', 'kwargs'] sig_params = filter( lambda param: param.name not in params_to_skip, sig.parameters.values() @@ -171,5 +259,12 @@ class PluginService: return params + @staticmethod + def describe_target(plugin_name, target_name, target): + parameters = PluginService.callable_params_desc(target.__init__) + target_id = PluginService.target_id(plugin_name, target_name) + return {'id': target_id, 'parameters': parameters} + + if __name__ == '__main__': app.run(host='localhost', port=5000) diff --git a/connectors/connector-bamboohr/connector_bamboohr/commands/__init__.py b/connectors/connector-bamboohr/connector_bamboohr/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/connectors/connector-bamboohr/connector_bamboohr/getPayRate.py b/connectors/connector-bamboohr/connector_bamboohr/commands/getPayRate.py similarity index 100% rename from connectors/connector-bamboohr/connector_bamboohr/getPayRate.py rename to connectors/connector-bamboohr/connector_bamboohr/commands/getPayRate.py diff --git a/connectors/connector-xero/connector_xero/auths/__init__.py b/connectors/connector-xero/connector_xero/auths/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/connectors/connector-xero/connector_xero/auths/oauth.py b/connectors/connector-xero/connector_xero/auths/oauth.py new file mode 100644 index 0000000..cd13399 --- /dev/null +++ b/connectors/connector-xero/connector_xero/auths/oauth.py @@ -0,0 +1,26 @@ +class OAuth: + def __init__(self, client_id: str, client_secret: str): + self.client_id = client_id + self.client_secret = client_secret + + def app_description(self): + return { + "name": "xero", + "version": "2", + "client_id": self.client_id, + "client_secret": self.client_secret, + "endpoint_url": "https://api.xero.com/", + "authorization_url": "https://login.xero.com/identity/connect/authorize", + "access_token_url": "https://identity.xero.com/connect/token", + "refresh_token_url": "https://identity.xero.com/connect/token", + "scope": "offline_access openid profile email accounting.transactions " + "accounting.reports.read accounting.journals.read accounting.settings " + "accounting.contacts accounting.attachments assets projects", + } + + @staticmethod + def filtered_params(params): + return { + "client_id": params["client_id"], + "client_secret": params["client_secret"], + } diff --git a/connectors/connector-xero/connector_xero/commands/__init__.py b/connectors/connector-xero/connector_xero/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/connectors/connector-xero/connector_xero/createInvoice.py b/connectors/connector-xero/connector_xero/commands/createInvoice.py similarity index 100% rename from connectors/connector-xero/connector_xero/createInvoice.py rename to connectors/connector-xero/connector_xero/commands/createInvoice.py diff --git a/poetry.lock b/poetry.lock index e7e265f..b1e2d9b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,11 @@ +[[package]] +name = "cachelib" +version = "0.9.0" +description = "A collection of cache libraries in the same API interface." +category = "main" +optional = false +python-versions = ">=3.7" + [[package]] name = "certifi" version = "2022.6.15.2" @@ -69,7 +77,7 @@ type = "directory" url = "connectors/connector-xero" [[package]] -name = "flask" +name = "Flask" version = "2.2.2" description = "A simple framework for building complex web applications." category = "main" @@ -86,6 +94,32 @@ Werkzeug = ">=2.2.2" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] +[[package]] +name = "Flask-OAuthlib" +version = "0.9.6" +description = "OAuthlib for Flask" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +cachelib = "*" +Flask = "*" +oauthlib = ">=1.1.2,<2.0.3 || >2.0.3,<2.0.4 || >2.0.4,<2.0.5 || >2.0.5,<3.0.0" +requests-oauthlib = ">=0.6.2,<1.2.0" + +[[package]] +name = "Flask-Session" +version = "0.4.0" +description = "Adds server-side session support to your Flask application" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +cachelib = "*" +Flask = ">=0.8" + [[package]] name = "gunicorn" version = "20.1.0" @@ -141,6 +175,20 @@ category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "oauthlib" +version = "2.1.0" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +rsa = ["cryptography"] +signals = ["blinker"] +signedtoken = ["cryptography", "pyjwt (>=1.0.0)"] +test = ["blinker", "cryptography", "mock", "nose", "pyjwt (>=1.0.0)", "unittest2"] + [[package]] name = "python-dateutil" version = "2.8.2" @@ -170,6 +218,21 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-oauthlib" +version = "1.1.0" +description = "OAuthlib authentication support for Requests." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +oauthlib = ">=2.1.0,<3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=2.1.0,<3.0.0)"] + [[package]] name = "setuptools" version = "65.3.0" @@ -234,9 +297,13 @@ urllib3 = "*" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "19cbf9f943c686aa5e4f7a5d3490059d2af3f50d2b3b22614834fec97d4a7efc" +content-hash = "0eb091f1ca8474234eb6645715477ea69507e2a66773cd0669f53b85d5054136" [metadata.files] +cachelib = [ + {file = "cachelib-0.9.0-py3-none-any.whl", hash = "sha256:811ceeb1209d2fe51cd2b62810bd1eccf70feba5c52641532498be5c675493b3"}, + {file = "cachelib-0.9.0.tar.gz", hash = "sha256:38222cc7c1b79a23606de5c2607f4925779e37cdcea1c2ad21b8bae94b5425a5"}, +] certifi = [ {file = "certifi-2022.6.15.2-py3-none-any.whl", hash = "sha256:0aa1a42fbd57645fabeb6290a7687c21755b0344ecaeaa05f4e9f6207ae2e9a8"}, {file = "certifi-2022.6.15.2.tar.gz", hash = "sha256:aa08c101214127b9b0472ca6338315113c9487d45376fd3e669201b477c71003"}, @@ -255,10 +322,18 @@ colorama = [ ] connector-bamboohr = [] connector-xero = [] -flask = [ +Flask = [ {file = "Flask-2.2.2-py3-none-any.whl", hash = "sha256:b9c46cc36662a7949f34b52d8ec7bb59c0d74ba08ba6cb9ce9adc1d8676d9526"}, {file = "Flask-2.2.2.tar.gz", hash = "sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b"}, ] +Flask-OAuthlib = [ + {file = "Flask-OAuthlib-0.9.6.tar.gz", hash = "sha256:5bb79c8a8e670c2eb4cb553dfc3283b6c8d1202f674934676dc173cee94fe39c"}, + {file = "Flask_OAuthlib-0.9.6-py3-none-any.whl", hash = "sha256:a5c3b62959aa1922470a62b6ebf4273b75f1c29561a7eb4a69cde85d45a1d669"}, +] +Flask-Session = [ + {file = "Flask-Session-0.4.0.tar.gz", hash = "sha256:c9ed54321fa8c4ca0132ffd3369582759eda7252fb4b3bee480e690d1ba41f46"}, + {file = "Flask_Session-0.4.0-py2.py3-none-any.whl", hash = "sha256:1e3f8a317005db72c831f85d884a5a9d23145f256c730d80b325a3150a22c3db"}, +] gunicorn = [ {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, @@ -317,6 +392,10 @@ markupsafe = [ {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, ] +oauthlib = [ + {file = "oauthlib-2.1.0-py2.py3-none-any.whl", hash = "sha256:d883b36b21a6ad813953803edfa563b1b579d79ca758fe950d1bc9e8b326025b"}, + {file = "oauthlib-2.1.0.tar.gz", hash = "sha256:ac35665a61c1685c56336bda97d5eefa246f1202618a1d6f34fccb1bdd404162"}, +] python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, @@ -325,6 +404,11 @@ requests = [ {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, ] +requests-oauthlib = [ + {file = "requests-oauthlib-1.1.0.tar.gz", hash = "sha256:eabd8eb700ebed81ba080c6ead96d39d6bdc39996094bd23000204f6965786b0"}, + {file = "requests_oauthlib-1.1.0-py2.py3-none-any.whl", hash = "sha256:be76f2bb72ca5525998e81d47913e09b1ca8b7957ae89b46f787a79e68ad5e61"}, + {file = "requests_oauthlib-1.1.0-py3.7.egg", hash = "sha256:490229d14a98e1b69612dcc1a22887ec14f5487dc1b8c6d7ba7f77a42ce7347b"}, +] setuptools = [ {file = "setuptools-65.3.0-py3-none-any.whl", hash = "sha256:2e24e0bec025f035a2e72cdd1961119f557d78ad331bb00ff82efb2ab8da8e82"}, {file = "setuptools-65.3.0.tar.gz", hash = "sha256:7732871f4f7fa58fb6bdcaeadb0161b2bd046c85905dbaa066bdcbcc81953b57"}, diff --git a/pyproject.toml b/pyproject.toml index 225e352..1c17462 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,11 +6,13 @@ authors = ["Jon Herron "] [tool.poetry.dependencies] python = "^3.10" -Flask = "*" +Flask = "^2.2.2" connector-xero = {develop=true, path="connectors/connector-xero"} connector-bamboohr = {develop=true, path="connectors/connector-bamboohr"} gunicorn = "^20.1.0" +Flask-OAuthlib = "^0.9.6" +Flask-Session = "^0.4.0" [tool.poetry.dev-dependencies]