Allow connectors to specify their auth requirements (#1)

This commit is contained in:
jbirddog 2022-09-21 17:22:33 -04:00 committed by GitHub
parent 8cd38a3845
commit 22ece99354
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 241 additions and 33 deletions

1
.gitignore vendored
View File

@ -64,6 +64,7 @@ db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
config.py
# Scrapy stuff:
.scrapy

153
app.py
View File

@ -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/<plugin_display_name>/<auth_name>')
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/<plugin_display_name>/<auth_name>/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/<plugin_display_name>/<command_name>')
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)

View File

@ -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"],
}

90
poetry.lock generated
View File

@ -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"},

View File

@ -6,11 +6,13 @@ authors = ["Jon Herron <jon.herron@yahoo.com>"]
[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]