Allow connectors to specify their auth requirements (#1)
This commit is contained in:
parent
8cd38a3845
commit
22ece99354
|
@ -64,6 +64,7 @@ db.sqlite3-journal
|
|||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
config.py
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
|
153
app.py
153
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/<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)
|
||||
|
|
|
@ -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"],
|
||||
}
|
|
@ -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"},
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
Loading…
Reference in New Issue