Skip to content

Commit

Permalink
feat(oauth2): added the OAuth2Connector, a helper class used to retri…
Browse files Browse the repository at this point in the history
…eve and store OAuth2 tokens

we also added a quickstart to show how to used it, ans provided unit tests.
We adapted the google_sheets_2 connector to use the OAuth2Connector
  • Loading branch information
davinov authored and luc-leonard committed Oct 14, 2020
1 parent 3c7d1bb commit 932c159
Show file tree
Hide file tree
Showing 12 changed files with 555 additions and 24 deletions.
107 changes: 107 additions & 0 deletions .ipynb_checkpoints/Method forwarding in connectors-checkpoint.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 38,
"metadata": {},
"outputs": [],
"source": [
"from pydantic import BaseModel\n",
"from typing import Callable"
]
},
{
"cell_type": "code",
"execution_count": 39,
"metadata": {},
"outputs": [],
"source": [
"class DummyDataSource(BaseModel):\n",
" table: str\n",
"\n",
"class DummyConnector(BaseModel):\n",
" host: str\n",
" secrets_id: str\n",
" \n",
" get_secrets: Callable[[str], dict]\n",
" \n",
" def get_df(self, data_source: DummyDataSource):\n",
" print('get_df for table', data_source.table)\n",
" print('with secrets: ', self.get_secrets(self.secrets_id))"
]
},
{
"cell_type": "code",
"execution_count": 40,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"get_df for table plop\n",
"with secrets: {'token': '1234567890'}\n"
]
}
],
"source": [
"def get_dummy_secrets(secrets_id):\n",
" if secrets_id == 'aaa':\n",
" return {'token': '1234567890'}\n",
" elif secrets_id == 'bbb':\n",
" return {'token': 'azertyuiop'}\n",
"\n",
"conn_config = {'host': '0.2.3.4', 'secrets_id': 'aaa'}\n",
"conn = DummyConnector(get_secrets=get_dummy_secrets, **conn_config)\n",
"ds = DummyDataSource(table='plop')\n",
"\n",
"conn.get_df(ds)"
]
},
{
"cell_type": "code",
"execution_count": 41,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'title': 'DummyConnector',\n",
" 'type': 'object',\n",
" 'properties': {'host': {'title': 'Host', 'type': 'string'},\n",
" 'secrets_id': {'title': 'Secrets Id', 'type': 'string'}},\n",
" 'required': ['host', 'secrets_id']}"
]
},
"execution_count": 41,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"DummyConnector.schema()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.7.4"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
107 changes: 107 additions & 0 deletions Method forwarding in connectors.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 38,
"metadata": {},
"outputs": [],
"source": [
"from pydantic import BaseModel\n",
"from typing import Callable"
]
},
{
"cell_type": "code",
"execution_count": 39,
"metadata": {},
"outputs": [],
"source": [
"class DummyDataSource(BaseModel):\n",
" table: str\n",
"\n",
"class DummyConnector(BaseModel):\n",
" host: str\n",
" secrets_id: str\n",
" \n",
" get_secrets: Callable[[str], dict]\n",
" \n",
" def get_df(self, data_source: DummyDataSource):\n",
" print('get_df for table', data_source.table)\n",
" print('with secrets: ', self.get_secrets(self.secrets_id))"
]
},
{
"cell_type": "code",
"execution_count": 42,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"get_df for table plop\n",
"with secrets: {'token': '1234567890'}\n"
]
}
],
"source": [
"def get_dummy_secrets(secrets_id):\n",
" if secrets_id == 'aaa':\n",
" return {'token': '1234567890'}\n",
" elif secrets_id == 'bbb':\n",
" return {'token': 'azertyuiop'}\n",
"\n",
"conn_config = {'host': '0.2.3.4', 'secrets_id': 'aaa'}\n",
"conn = DummyConnector(get_secrets=get_dummy_secrets, **conn_config)\n",
"ds = DummyDataSource(table='plop')\n",
"\n",
"conn.get_df(ds)"
]
},
{
"cell_type": "code",
"execution_count": 43,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'title': 'DummyConnector',\n",
" 'type': 'object',\n",
" 'properties': {'host': {'title': 'Host', 'type': 'string'},\n",
" 'secrets_id': {'title': 'Secrets Id', 'type': 'string'}},\n",
" 'required': ['host', 'secrets_id']}"
]
},
"execution_count": 43,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"DummyConnector.schema()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.7.4"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
18 changes: 18 additions & 0 deletions oauth_connector_quickstart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from toucan_connectors.google_sheets_2.google_sheets_2_connector import GoogleSheets2Connector, GoogleSheets2DataSource
from toucan_connectors.oauth2_connector.oauth2_authorization_webserver import get_authorization_response
from toucan_connectors.oauth2_connector.oauth2connector import JsonFileSecretsKeeper


CLIENT_ID = ''
CLIENT_SECRET = ''
REDIRECT_URI = 'http://localhost:34097/'

google_sheets_conn = GoogleSheets2Connector(name='test', auth_flow_id='test', client_id=CLIENT_ID, client_secret=CLIENT_SECRET,
redirect_uri=REDIRECT_URI, secrets_keeper=JsonFileSecretsKeeper(filename="secrets.json"))
sample_data_source_ss = GoogleSheets2DataSource(name='test', domain='test-connector', spreadsheet_id='1L5YraXEToFv7p0HMke7gXI4IhJotdT0q5bk_PInI1hA')

# authorization_response = get_authorization_response(google_sheets_conn.build_authorization_url(), 'localhost', 34097)
# google_sheets_conn.retrieve_tokens(authorization_response)

df = google_sheets_conn.get_df(data_source=sample_data_source_ss)
print(df)
1 change: 1 addition & 0 deletions requirements-testing.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
authlib == 0.14
aioresponses >= 0.6.0
black
cryptography >= 2.7.0
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
extras_require['all'] = sorted(set(sum(extras_require.values(), [])))

install_requires = [
'authlib',
'aiohttp',
'cached_property',
'jinja2',
Expand Down
Empty file.
124 changes: 124 additions & 0 deletions tests/oauth2_connector/test_oauth2connector.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from typing import Any
from unittest.mock import Mock

import pytest

from toucan_connectors.oauth2_connector.oauth2connector import (
NoOAuth2RefreshToken,
OAuth2Connector,
OAuth2Session,
SecretsKeeper,
)

FAKE_AUTHORIZATION_URL = 'http://localhost:4242/foobar'
FAKE_TOKEN_URL = 'http://service/token_endpoint'
SCOPE: str = "openid email https://www.googleapis.com/auth/spreadsheets.readonly"


@pytest.fixture
def secrets_keeper():
class SimpleSecretsKeeper(SecretsKeeper):
def __init__(self):
self.store = {}

def load(self, key: str) -> Any:
return self.store[key]

def save(self, key: str, value: Any):
self.store[key] = value

return SimpleSecretsKeeper()


@pytest.fixture
def oauth2_connector(secrets_keeper):
return OAuth2Connector(
name='test',
authorization_url=FAKE_AUTHORIZATION_URL,
scope=SCOPE,
client_id='',
client_secret='',
redirect_uri='',
token_url=FAKE_TOKEN_URL,
secrets_keeper=secrets_keeper,
)


def test_build_authorization_url(mocker, oauth2_connector, secrets_keeper):
"""
It should return the authorization URL
"""
mock_create_authorization_url: Mock = mocker.patch(
'toucan_connectors.oauth2_connector.oauth2connector.OAuth2Session.create_authorization_url',
return_value=('authorization_url', 'state'),
)
url = oauth2_connector.build_authorization_url()
mock_create_authorization_url.assert_called_once_with(FAKE_AUTHORIZATION_URL)
assert url == 'authorization_url'
assert secrets_keeper.load('test')['state'] == 'state'


def test_retrieve_tokens(mocker, oauth2_connector, secrets_keeper):
"""
It should retrieve tokens and save them
"""
secrets_keeper.save('test', {'state': 'dummy_state'})
mock_fetch_token: Mock = mocker.patch(
'toucan_connectors.oauth2_connector.oauth2connector.OAuth2Session.fetch_token',
return_value={'access_token': 'dummy_token'},
)

oauth2_connector.retrieve_tokens('http://localhost/?state=dummy_state')
mock_fetch_token.assert_called()
assert secrets_keeper.load('test')['access_token'] == 'dummy_token'


def test_fail_retrieve_tokens(oauth2_connector, secrets_keeper):
"""
It should fail ig the stored state does not match the received state
"""
secrets_keeper.save('test', {'state': 'dummy_state'})

with pytest.raises(AssertionError):
oauth2_connector.retrieve_tokens('http://localhost/?state=bad_state')


def test_get_access_token(oauth2_connector, secrets_keeper):
"""
It should return the last saved access_token
"""
secrets_keeper.save('test', {'access_token': 'dummy_token'})
assert oauth2_connector.get_access_token() == 'dummy_token'


def test_get_access_token_expired(mocker, oauth2_connector, secrets_keeper):
"""
It should refresh the token if it expired
"""
secrets_keeper.save(
'test',
{'access_token': 'dummy_token', 'expires_at': 0, 'refresh_token': 'dummy_refresh_token'},
)

mock_refresh_token: Mock = mocker.patch(
'toucan_connectors.oauth2_connector.oauth2connector.OAuth2Session.refresh_token',
return_value={'access_token': 'new_token'},
)
access_token = oauth2_connector.get_access_token()
mock_refresh_token.assert_called_once_with(FAKE_TOKEN_URL, refresh_token='dummy_refresh_token')
assert access_token == 'new_token'


def test_get_access_token_expired_no_refresh_token(mocker, oauth2_connector, secrets_keeper):
"""
It should fail to refresh the token if no refresh token is provided
"""
secrets_keeper.save('test', {'access_token': 'dummy_token', 'expires_at': 0})

mock_refresh_token: Mock = mocker.patch(
'toucan_connectors.oauth2_connector.oauth2connector.OAuth2Session.refresh_token',
return_value={'access_token': 'new_token'},
)
with pytest.raises(NoOAuth2RefreshToken):
oauth2_connector.get_access_token()
mock_refresh_token.assert_not_called()
Loading

0 comments on commit 932c159

Please sign in to comment.