Skip to content

Commit

Permalink
Merge pull request #28 from jamesholcombe/dev
Browse files Browse the repository at this point in the history
Dev
  • Loading branch information
jamesholcombe committed Jul 15, 2023
2 parents f9f4fbe + c6f8178 commit 639e1cf
Show file tree
Hide file tree
Showing 7 changed files with 121 additions and 53 deletions.
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
97 changes: 50 additions & 47 deletions dash_auth_external/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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__,
):
Expand All @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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 = {
Expand All @@ -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
11 changes: 9 additions & 2 deletions dash_auth_external/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
1 change: 1 addition & 0 deletions dash_auth_external/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class OAuth2Token:
expires_in: int = None
refresh_token: str = None
expires_at: float = None
token_data: dict = None

def __post_init__(self):
if self.expires_at is None and self.expires_in is not None:
Expand Down
12 changes: 9 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"
29 changes: 29 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}


Expand All @@ -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,
},
}


Expand Down Expand Up @@ -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

0 comments on commit 639e1cf

Please sign in to comment.