From 682dff21e250f26380412234f1fa35fe97d112ce Mon Sep 17 00:00:00 2001 From: subsurfaceiodev <109676812+subsurfaceiodev@users.noreply.github.com> Date: Thu, 13 Jul 2023 17:34:44 -0500 Subject: [PATCH 1/4] Update token.py This enables Google's oauth2 authentication --- dash_auth_external/token.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dash_auth_external/token.py b/dash_auth_external/token.py index 44272fc..10c265c 100644 --- a/dash_auth_external/token.py +++ b/dash_auth_external/token.py @@ -9,6 +9,8 @@ class OAuth2Token: expires_in: int = None refresh_token: str = None expires_at: float = None + scope: str = None + id_token: str = None def __post_init__(self): if self.expires_at is None and self.expires_in is not None: From 990a6d33c401e0e6a3d2b84b2468bbd51a90d6b1 Mon Sep 17 00:00:00 2001 From: James Holcombe Date: Fri, 14 Jul 2023 18:13:43 +0100 Subject: [PATCH 2/4] update token class and add get data method --- dash_auth_external/auth.py | 97 +++++++++++++++++++----------------- dash_auth_external/routes.py | 11 +++- dash_auth_external/token.py | 3 +- 3 files changed, 60 insertions(+), 51 deletions(-) diff --git a/dash_auth_external/auth.py b/dash_auth_external/auth.py index 1fd272a..7fca6db 100644 --- a/dash_auth_external/auth.py +++ b/dash_auth_external/auth.py @@ -10,6 +10,15 @@ from flask import session +def generate_secret_key(length: int = 24) -> str: + """Generates a secret key for flask app. + + Returns: + bytes: Random bytes of the desired length. + """ + return os.urandom(length) + + def _get_token_data_from_session() -> dict: """Gets the token data from the session. @@ -27,39 +36,6 @@ def _set_token_data_in_session(token: OAuth2Token): class DashAuthExternal: - @staticmethod - def generate_secret_key(length: int = 24) -> str: - """Generates a secret key for flask app. - - Returns: - bytes: Random bytes of the desired length. - """ - return os.urandom(length) - - def get_token(self) -> str: - """Attempts to get a valid access token. - - Returns: - str: Bearer Access token from your OAuth2 Provider - """ - token_data = _get_token_data_from_session() - - token = OAuth2Token(**token_data) - - if not token.is_expired(): - return token.access_token - - if not token.refresh_token: - raise TokenExpiredError( - "Token is expired and no refresh token available to refresh token." - ) - - token_data = refresh_token( - self.external_token_url, token_data, self.token_request_headers - ) - _set_token_data_in_session(token_data) - return token_data.access_token - def __init__( self, external_auth_url: str, @@ -72,11 +48,9 @@ def __init__( auth_suffix: str = "/", home_suffix="/home", _flask_server: Flask = None, - _token_field_name: str = "access_token", _secret_key: str = None, auth_request_headers: dict = None, token_request_headers: dict = None, - token_body_params: dict = None, scope: str = None, _server_name: str = __name__, ): @@ -95,7 +69,6 @@ def __init__( _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. token_request_headers (dict, optional): Additional headers to send to the access token endpoint. Defaults to None. - token_body_params (dict, optional): Additional body params to send to the access token endpoint. Defaults to None. scope (str, optional): Header required by most Oauth2 Providers. Defaults to None. _server_name (str, optional): The name of the Flask Server. Defaults to __name__, ignored if _flask_server is not None. @@ -117,7 +90,7 @@ def __init__( app = _flask_server if _secret_key is None: - app.secret_key = self.generate_secret_key() + app.secret_key = generate_secret_key() else: app.secret_key = _secret_key @@ -149,12 +122,43 @@ def __init__( self.home_suffix = home_suffix self.redirect_suffix = redirect_suffix self.auth_suffix = auth_suffix - self._token_field_name = _token_field_name self.client_id = client_id self.external_token_url = external_token_url self.token_request_headers = token_request_headers self.scope = scope + def get_token_data(self) -> OAuth2Token: + """Attempts to get a valid access token. + + Returns: + OAuth2Token: The token data. + """ + token_data = _get_token_data_from_session() + + token = OAuth2Token(**token_data) + + if not token.is_expired(): + return token + + if not token.refresh_token: + raise TokenExpiredError( + "Token is expired and no refresh token available to refresh token." + ) + + token_data = refresh_token( + self.external_token_url, token_data, self.token_request_headers + ) + _set_token_data_in_session(token_data) + return token_data + + def get_token(self) -> str: + """Attempts to get a valid access token. + + Returns: + str: The access token. + """ + return self.get_token_data().access_token + def refresh_token(url: str, token_data: OAuth2Token, headers: dict) -> OAuth2Token: body = { @@ -163,13 +167,12 @@ def refresh_token(url: str, token_data: OAuth2Token, headers: dict) -> OAuth2Tok } data = token_request(url, body, headers) - token_data.access_token = data["access_token"] + new_token = OAuth2Token( + access_token=data["access_token"], + token_type=data.get("token_type"), + expires_in=data.get("expires_in"), + refresh_token=data.get("refresh_token"), + token_data=data, + ) - # If the provider does not return a new refresh token, use the old one. - if "refresh_token" in data: - token_data.refresh_token = data["refresh_token"] - - if "expires_in" in data: - token_data.expires_in = data["expires_in"] - - return token_data + return new_token diff --git a/dash_auth_external/routes.py b/dash_auth_external/routes.py index d75ecf9..07f03e1 100644 --- a/dash_auth_external/routes.py +++ b/dash_auth_external/routes.py @@ -117,17 +117,24 @@ def get_token_route(): body=body, headers=token_request_headers, ) + token = OAuth2Token( + access_token=response_data.get("access_token"), + token_type=response_data.get("token_type"), + expires_in=response_data.get("expires_in"), + refresh_token=response_data.get("refresh_token"), + token_data=response_data, + ) response = redirect(_home_suffix) - session[FLASK_SESSION_TOKEN_KEY] = asdict(OAuth2Token(**response_data)) + session[FLASK_SESSION_TOKEN_KEY] = asdict(token) return response return app -def token_request(url: str, body: dict, headers: dict): +def token_request(url: str, body: dict, headers: dict) -> dict: r = requests.post(url, data=body, headers=headers) r.raise_for_status() return r.json() diff --git a/dash_auth_external/token.py b/dash_auth_external/token.py index 10c265c..15ffb4f 100644 --- a/dash_auth_external/token.py +++ b/dash_auth_external/token.py @@ -9,8 +9,7 @@ class OAuth2Token: expires_in: int = None refresh_token: str = None expires_at: float = None - scope: str = None - id_token: str = None + token_data: dict = None def __post_init__(self): if self.expires_at is None and self.expires_in is not None: From 0f13cee5d8216441586b33397b4124835e0d7263 Mon Sep 17 00:00:00 2001 From: James Holcombe Date: Sat, 15 Jul 2023 19:18:48 +0100 Subject: [PATCH 3/4] test and docs for get_token_data --- README.md | 22 +++++++++++++++++++++- tests/test_app.py | 2 ++ tests/test_auth.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f8da722..6699e09 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Do you want to build a Plotly Dash app which pulls user data from external APIs pip install dash-auth-external ``` -## Simple Usage +## Usage ```python #using spotify as an example @@ -55,7 +55,27 @@ Input("example-input", "value") def example_callback(value): token = auth.get_token() ##The token can only be retrieved in the context of a dash callback + + token_data = auth.get_token_data() + # get_token_data can be used to access other data returned by the OAuth Provider + print(token) + print(token_data) + return token + +``` + +Results in something like: + +```bash +>>> fakeToken123 +>>> { + "access_token" : "fakeToken123", + "user_id" : "lucifer", + "some_other_key" : 666, + "expires_at" : "judgmentDay" +} + ``` ## Refresh Tokens diff --git a/tests/test_app.py b/tests/test_app.py index 622781b..d054706 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -44,6 +44,7 @@ def test_flow(with_pkce, with_client_secret, mocker): "refresh_token": "refresh_token", "token_type": "Bearer", "expires_in": "3599", + "arbitrary_key": "some_data", }, ) expected_token_request_body = { @@ -93,3 +94,4 @@ def test_flow(with_pkce, with_client_secret, mocker): assert token_data["refresh_token"] == "refresh_token" assert token_data["token_type"] == "Bearer" assert token_data["expires_in"] == "3599" + assert token_data["token_data"]["arbitrary_key"] == "some_data" diff --git a/tests/test_auth.py b/tests/test_auth.py index 3cbc3d6..f9b436e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -26,6 +26,12 @@ def access_token_data_with_refresh(): "refresh_token": "refresh_token", "token_type": "Bearer", "expires_in": 3599, + "token_data": { + "access_token": "access_token", + "refresh_token": "refresh_token", + "token_type": "Bearer", + "expires_in": 3599, + }, } @@ -35,6 +41,11 @@ def expired_access_token_data_without_refresh(): "access_token": "access_token", "token_type": "Bearer", "expires_in": -1, + "token_data": { + "access_token": "access_token", + "token_type": "Bearer", + "expires_in": -1, + }, } @@ -114,3 +125,21 @@ def test_callback(value): with pytest.raises(TokenExpiredError): test_callback("test") + + +def test_get_token_data_ok(dash_app_and_auth, mocker, access_token_data_with_refresh): + dash_app, auth = dash_app_and_auth + token = OAuth2Token( + **access_token_data_with_refresh, + ) + + mocker.patch( + "dash_auth_external.auth._get_token_data_from_session", + return_value=access_token_data_with_refresh, + ) + + token_compare = auth.get_token_data() + assert isinstance(token_compare.expires_at, float) + token_compare.expires_at = None + token.expires_at = None + assert token_compare == token From c6f8178a179c4aca551b357f88e12c3c8875bc61 Mon Sep 17 00:00:00 2001 From: James Holcombe Date: Sat, 15 Jul 2023 19:20:05 +0100 Subject: [PATCH 4/4] update version no --- setup.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 8abcfa6..b589f90 100644 --- a/setup.py +++ b/setup.py @@ -5,15 +5,21 @@ NAME = "dash-auth-external" this_directory = Path(__file__).parent -long_description = ( +description = ( "Integrate your dashboards with 3rd party APIs and external OAuth providers." ) + +with open(this_directory / "README.md", encoding="utf-8") as f: + long_description = f.read() + + + requires = ["dash >= 2.0.0", "requests >= 1.0.0", "requests-oauthlib >= 0.3.0"] setup( name=NAME, - version="1.1.0", - description=long_description, + version="1.2.0", + description=description, python_requires=">=3.7", author_email="jholcombe@hotmail.co.uk", url="https://github.com/jamesholcombe/dash-auth-external",