From cdbe97f9fcd89082df13ad8eca69f402b672f68c Mon Sep 17 00:00:00 2001 From: James Holcombe Date: Wed, 19 Oct 2022 23:36:39 +0100 Subject: [PATCH] light refactor and cleanup --- .github/workflows/python-package.yml | 43 +++++++++++------------ .github/workflows/python-publish.yml | 37 +++++++++----------- README.md | 47 ++++++++++--------------- dash_auth_external/auth.py | 13 ++----- dash_auth_external/routes.py | 52 ++++++++++------------------ dev-requirements.txt | 2 ++ requirements.txt | 26 ++------------ setup.py | 6 ++-- tests/test_app.py | 3 +- 9 files changed, 87 insertions(+), 142 deletions(-) create mode 100644 dev-requirements.txt diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index fa14147..0bb6a39 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -5,13 +5,12 @@ name: Python package on: push: - branches: [ master ] + branches: [main] pull_request: - branches: [ master ] + branches: [main] jobs: build: - runs-on: ubuntu-latest strategy: fail-fast: false @@ -19,22 +18,22 @@ jobs: python-version: ["3.8", "3.9", "3.10"] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest pytest-mock + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 3bfabfc..c1a9339 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -8,29 +8,26 @@ name: Upload Python Package -on: - release: - types: [published] +on: -release jobs: deploy: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/README.md b/README.md index f1cba51..d71190a 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,23 @@ # dash-auth-external - Integrate your dashboards with 3rd party APIs and external OAuth providers. +Integrate your dashboards with 3rd party APIs and external OAuth providers. ## Overview Do you want to build a Plotly Dash app which pulls user data from external APIs such as Google, Spotify, Slack etc? -**Dash-auth-external** provides a simple interface to authenticate users through OAuth2 code flow. Allowing developers to serve user tailored content. +**Dash-auth-external** provides a simple interface to authenticate users through OAuth2 code flow. Allowing developers to serve user tailored content. ## Installation + **Dash-auth-external** is distributed via [PyPi](https://pypi.org/project/dash-auth-external/) ``` pip install dash-auth-external ``` + ## Simple Usage + ```python #using spotify as an example AUTH_URL = "https://accounts.spotify.com/authorize" @@ -24,17 +27,22 @@ CLIENT_ID = "YOUR_CLIENT_ID" # creating the instance of our auth class auth = DashAuthExternal(AUTH_URL, TOKEN_URL, CLIENT_ID) ``` + We then pass the flask server from this object to dash on init. + ```python app = Dash(__name__, server= auth.server) ``` -That's it! You can now define your layout and callbacks as usual. + +That's it! You can now define your layout and callbacks as usual. + > To obtain your access token, call the get_token method of your Auth object. > **NOTE** This can **ONLY** be done in the context of a dash callback. + ```python app.layout = html.Div( [ -html.Div(id="example-output"), +html.Div(id="example-output"), dcc.Input(id="example-input") ]) @@ -48,38 +56,21 @@ def example_callback(value): ) ##The token can only be retrieved in the context of a dash callback return token ``` + ## Troubleshooting If you hit 400 responses (bad request) from either endpoint, there are a number of things that might need configuration. -Make sure you have checked the following - -- **Register your redirect URI** with OAuth provider! - -*The library uses a default redirect URI of http://127.0.0.1:8050/redirect*. +Make sure you have checked the following -- Check whether your OAuth provider requires a client secret as well as a client id. +- **Register your redirect URI** with OAuth provider! -*This can be passed as a keyword argument to the main class.* +_The library uses a default redirect URI of http://127.0.0.1:8050/redirect_. - Check the **key field** for the **token** in the JSON response returned by the token endpoint by your OAuth provider. -*The default is "access_token" but different OAuth providers will use a different key for this.* - - -## Feature Roadmap - -- [x] OAuth2 support -- [ ] OAuth1 support -- [x] Full test coverage -- [x] Support for PKCE/ non-PKCE - - - - - - - - +_The default is "access_token" but different OAuth providers may use a different key for this._ +## Contributing +Contributions, issues, and ideas are all more than welcome diff --git a/dash_auth_external/auth.py b/dash_auth_external/auth.py index 0ab45e7..d7b1500 100644 --- a/dash_auth_external/auth.py +++ b/dash_auth_external/auth.py @@ -29,11 +29,6 @@ def get_token(self) -> str: ) return token - # except RuntimeError: - # raise ValueError( - # "This method must be called in a callback as it makes use of the flask request context." - # ) - def __init__( self, external_auth_url: str, @@ -45,7 +40,6 @@ def __init__( auth_suffix: str = "/", home_suffix="/home", _token_field_name: str = "access_token", - client_secret: str = None, _secret_key: str = None, auth_request_headers: dict = None, token_request_headers: dict = None, @@ -63,9 +57,8 @@ def __init__( auth_suffix (str, optional): The route that will trigger the initial redirect to the external OAuth provider. Defaults to "/". home_suffix (str, optional): The route your dash application will sit, relative to your url. Defaults to "/home". _token_field_name (str, optional): The key for the token returned in JSON from the token endpoint. Defaults to "access_token". - client_secret (str, optional): Client secret if enforced by Oauth2 provider. Defaults to None. _secret_key (str, optional): Secret key for flask app, normally generated at runtime. Defaults to None. - auth_request_headers (dict, optional): Additional headers to send to the authorization endpoint. Defaults to None. + auth_request_params (dict, optional): Additional params to send to the authorization endpoint. Defaults to None. token_request_headers (dict, optional): Additional headers to send to the access token endpoint. Defaults to None. scope (str, optional): Header required by most Oauth2 Providers. Defaults to None. @@ -85,18 +78,16 @@ def __init__( app=app, external_auth_url=external_auth_url, client_id=client_id, - client_secret=client_secret, auth_suffix=auth_suffix, redirect_uri=redirect_uri, with_pkce=with_pkce, scope=scope, - auth_request_headers=auth_request_headers, + auth_request_params=auth_request_headers, ) app = make_access_token_route( app, external_token_url=external_token_url, client_id=client_id, - client_secret=client_secret, redirect_uri=redirect_uri, redirect_suffix=redirect_suffix, _home_suffix=home_suffix, diff --git a/dash_auth_external/routes.py b/dash_auth_external/routes.py index 17aed8e..3187ece 100644 --- a/dash_auth_external/routes.py +++ b/dash_auth_external/routes.py @@ -12,7 +12,8 @@ def make_code_challenge(length: int = 40): - code_verifier = base64.urlsafe_b64encode(os.urandom(length)).decode("utf-8") + code_verifier = base64.urlsafe_b64encode( + os.urandom(length)).decode("utf-8") code_verifier = re.sub("[^a-zA-Z0-9]+", "", code_verifier) code_challenge = hashlib.sha256(code_verifier.encode("utf-8")).digest() code_challenge = base64.urlsafe_b64encode(code_challenge).decode("utf-8") @@ -27,9 +28,8 @@ def make_auth_route( auth_suffix: str, redirect_uri: str, with_pkce: bool = True, - client_secret: str = None, scope: str = None, - auth_request_headers: dict = None, + auth_request_params: dict = None, ): @app.route(auth_suffix) def get_auth_code(): @@ -37,13 +37,6 @@ def get_auth_code(): Redirect the user/resource owner to the OAuth provider using an URL with a few key OAuth parameters. """ - # making code verifier and challenge for PKCE - - if with_pkce: - code_challenge, code_verifier = make_code_challenge() - session["cv"] = code_verifier - - # TODO implement this myself oauth_session = OAuth2Session( client_id, redirect_uri=redirect_uri, @@ -51,15 +44,18 @@ def get_auth_code(): ) if with_pkce: - + code_challenge, code_verifier = make_code_challenge() + session["cv"] = code_verifier authorization_url, state = oauth_session.authorization_url( external_auth_url, code_challenge=code_challenge, code_challenge_method="S256", + **auth_request_params, ) else: authorization_url, state = oauth_session.authorization_url( external_auth_url, + **auth_request_params, ) resp = redirect(authorization_url) @@ -69,33 +65,24 @@ def get_auth_code(): def build_token_body( - url, redirect_uri: str, client_id: str, with_pkce: bool, client_secret: str + url: str, redirect_uri: str, client_id: str, with_pkce: bool, client_secret: str ): query = urllib.parse.urlparse(url).query redirect_params = urllib.parse.parse_qs(query) code = redirect_params["code"][0] state = redirect_params["state"][0] + body = dict( + grant_type="authorization_code", + code=code, + redirect_uri=redirect_uri, + client_id=client_id, + state=state, + ) if with_pkce: - code_verifier = session["cv"] - body = dict( - grant_type="authorization_code", - code=code, - redirect_uri=redirect_uri, - code_verifier=code_verifier, - client_id=client_id, - state=state, - client_secret=client_secret, - ) - else: - body = dict( - grant_type="authorization_code", - code=code, - redirect_uri=redirect_uri, - client_id=client_id, - state=state, - client_secret=client_secret, - ) + + body["code_verifier"] = session["cv"] + return body @@ -108,7 +95,6 @@ def make_access_token_route( client_id: str, _token_field_name: str, with_pkce: bool = True, - client_secret: str = None, token_request_headers: dict = None, ): @app.route(redirect_suffix, methods=["GET", "POST"]) @@ -119,7 +105,7 @@ def get_token(): redirect_uri=redirect_uri, with_pkce=with_pkce, client_id=client_id, - client_secret=client_secret, + ) response_data = get_token_response_data( diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..d0fe0a5 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,2 @@ +pytest +pytest-mock \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 154aedf..fa4c012 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,3 @@ -Brotli==1.0.9 -certifi==2021.10.8 -charset-normalizer==2.0.9 -click==8.0.3 -dash==2.0.0 -dash-auth-external==0.1 -dash-core-components==2.0.0 -dash-html-components==2.0.0 -dash-table==5.0.0 -Flask==2.0.2 -Flask-Compress==1.10.1 -idna==3.3 -itsdangerous==2.0.1 -Jinja2==3.0.3 -MarkupSafe==2.0.1 -oauthlib==3.1.1 -plotly==5.5.0 -requests==2.27.0 -requests-oauthlib==1.3.0 -six==1.16.0 -tenacity==8.0.1 -urllib3==1.26.7 -Werkzeug==2.0.2 +dash >= 2.0.0 +requests >= 1.0.0 +requests-oauthlib >= 0.3.0 diff --git a/setup.py b/setup.py index 8ae372a..1c6e877 100644 --- a/setup.py +++ b/setup.py @@ -1,21 +1,21 @@ from setuptools import setup, find_packages from pathlib import Path -REQUIRES = ["requests", "requests_oauthlib", "flask"] NAME = "dash-auth-external" this_directory = Path(__file__).parent long_description = (this_directory / "README.md").read_text() +requires = (this_directory / "requirements.txt").read_text().splitlines() setup( name=NAME, - version="0.2.3", + version="0.2.4", description="Integrate Dash with 3rd Parties and external providers", author_email="jholcombe@hotmail.co.uk", url="https://github.com/jamesholcombe/dash-auth-external", keywords=["Dash", "Plotly", "Authentication", "Auth", "External"], - install_requires=REQUIRES, + install_requires=requires, packages=find_packages(), include_package_data=True, long_description=long_description, diff --git a/tests/test_app.py b/tests/test_app.py index 8d908ae..2a4d6cf 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -4,13 +4,12 @@ from unittest.mock import Mock, patch import unittest from flask import request -from .test_config import * +from .test_config import EXERNAL_TOKEN_URL, EXTERNAL_AUTH_URL, CLIENT_ID """Module for integation tests """ -##this is the least intuitive thing in the world. Patches have to be opposite way round you would expect. @patch("dash_auth_external.routes.build_token_body") @patch("dash_auth_external.routes.get_token_response_data") def test_get_token(mock_post, mock_body):