Skip to content

Commit 94e1f16

Browse files
authored
new AuthClient (macrobond#82)
1 parent 7d559df commit 94e1f16

23 files changed

+687
-631
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ jobs:
5454
if: always()
5555
run: python scripts/test_setup.py
5656

57+
- name: Test pytest no_account
58+
if: always()
59+
run: python -m pytest -m no_account
60+
5761
- name: Pdoc3
5862
if: always()
5963
run: python scripts/lint_tools.py --pdoc3

.vscode/launch.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
{
22
"version": "0.2.0",
33
"configurations": [
4+
{
5+
"name": "Python: Debug Tests",
6+
"type": "python",
7+
"request": "launch",
8+
"program": "${file}",
9+
"purpose": ["debug-test"],
10+
"console": "integratedTerminal",
11+
"justMyCode": false,
12+
"presentation": {
13+
"hidden": true, // keep original launch order in 'run and debug' tab
14+
}
15+
}
416
],
517
"compounds": [],
618
}

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,6 @@ python -m pip install macrobond-data-api
9090

9191
Macrobond Data API for Python officially supports Python 3.8+.
9292

93-
## Using of system keyring for http proxy
94-
95-
For users operating behind an HTTP proxy, it is advisable to utilize the system keyring to store proxy settings and
96-
credentials.
97-
This can be conveniently accomplished by executing the included script with the following command:
98-
99-
```console
100-
python -c "from macrobond_data_api.util import *; save_proxy_to_keyring()"
101-
```
102-
10393
## Using of system keyring for credentials
10494

10595
> [!NOTE]
@@ -112,6 +102,16 @@ This can be done easily by running the include script using this command:
112102
python -c "from macrobond_data_api.util import *; save_credentials_to_keyring()"
113103
```
114104

105+
## Using of system keyring for http proxy
106+
107+
For users operating behind an HTTP proxy, it is advisable to utilize the system keyring to store proxy settings and
108+
credentials.
109+
This can be conveniently accomplished by executing the included script with the following command:
110+
111+
```console
112+
python -c "from macrobond_data_api.util import *; save_proxy_to_keyring()"
113+
```
114+
115115
### Supported keyrings
116116

117117
* macOS [Keychain](https://en.wikipedia.org/wiki/Keychain_%28software%29)

macrobond_data_api/_get_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import TYPE_CHECKING, Optional, List
44

55

6-
if TYPE_CHECKING:
6+
if TYPE_CHECKING: # pragma: no cover
77
from macrobond_data_api.common.api import Api
88

99
__MACROBOND_DATA_API_CURRENT_API: Optional["Api"] = None

macrobond_data_api/web/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,9 @@
33
from .configuration import Configuration
44
from .web_client import WebClient
55
from .data_package_list_poller import DataPackageListPoller
6+
from .auth_exceptions import (
7+
AuthBaseException,
8+
AuthDiscoveryException,
9+
AuthFetchTokenException,
10+
AuthInvalidCredentialsException,
11+
)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import time
2+
from typing import Any, Dict, Sequence, Optional, TYPE_CHECKING
3+
4+
from .auth_exceptions import AuthFetchTokenException, AuthDiscoveryException, AuthInvalidCredentialsException
5+
6+
if TYPE_CHECKING: # pragma: no cover
7+
from .scope import Scope
8+
from .session import Session
9+
from requests.models import PreparedRequest
10+
11+
12+
class _AuthClient:
13+
14+
def __init__(
15+
self, username: str, password: str, scope: Sequence["Scope"], authorization_url: str, session: "Session"
16+
) -> None:
17+
self._username = username
18+
self._password = password
19+
self.scope = " ".join((str(x) for x in scope))
20+
self.authorization_url = authorization_url
21+
self.token_endpoint: Optional[str] = None
22+
self.session = session
23+
self.requests_auth = self._requests_auth
24+
self.access_token = ""
25+
self.token_response: Optional[Dict[str, Any]] = None
26+
self.leeway = 60
27+
self.expires_at: Optional[int] = None
28+
self.fetch_token_get_time = time.time
29+
self.is_expired_get_time = time.time
30+
31+
def fetch_token_if_necessary(self) -> bool:
32+
if self._is_expired():
33+
self.fetch_token()
34+
return True
35+
return False
36+
37+
def fetch_token(self) -> None:
38+
if self.token_endpoint is None:
39+
self.token_endpoint = self._discovery(self.authorization_url)
40+
41+
self._fetch_token(self.token_endpoint)
42+
43+
def _fetch_token(self, token_endpoint: str) -> None:
44+
payload = {
45+
"grant_type": "client_credentials",
46+
"client_id": self._username,
47+
"client_secret": self._password,
48+
"scope": self.scope,
49+
}
50+
response = self.session.requests_session.post(
51+
token_endpoint, data=payload, headers={"Content-Type": "application/x-www-form-urlencoded"}
52+
)
53+
54+
if response.status_code not in [200, 400]:
55+
raise AuthFetchTokenException("status code is not 200 or 400")
56+
57+
try:
58+
json = self.token_response = response.json()
59+
except Exception as ex:
60+
raise AuthFetchTokenException("not valid json") from ex
61+
62+
if not isinstance(json, dict):
63+
raise AuthFetchTokenException("no root obj in json")
64+
65+
if response.status_code == 400:
66+
error = json.get("error")
67+
if error == "invalid_client":
68+
raise AuthInvalidCredentialsException("invalid client credentials")
69+
if isinstance(error, str):
70+
raise AuthFetchTokenException("error: " + error)
71+
raise AuthFetchTokenException("no error in response")
72+
73+
if json.get("token_type") and json["token_type"] != "Bearer":
74+
raise AuthFetchTokenException("token_type is not Bearer")
75+
76+
if json.get("expires_at"):
77+
self.expires_at = int(json["expires_at"])
78+
elif json.get("expires_in"):
79+
self.expires_at = int(self.fetch_token_get_time()) + int(json["expires_in"])
80+
else:
81+
raise AuthFetchTokenException("no expires_at or expires_in")
82+
83+
if not json.get("access_token"):
84+
raise AuthFetchTokenException("No access_token")
85+
86+
self.access_token = json["access_token"]
87+
88+
def _discovery(self, url: str) -> str:
89+
90+
response = self.session.requests_session.get(url + ".well-known/openid-configuration")
91+
if response.status_code != 200:
92+
raise AuthDiscoveryException("status code is not 200")
93+
94+
try:
95+
json: Optional[Dict[str, Any]] = response.json()
96+
except Exception as ex:
97+
raise AuthDiscoveryException("not valid json.") from ex
98+
99+
if not isinstance(json, dict):
100+
raise AuthDiscoveryException("no root obj in json.")
101+
102+
token_endpoint: Optional[str] = json.get("token_endpoint")
103+
if token_endpoint is None:
104+
raise AuthDiscoveryException("token_endpoint in root obj.")
105+
106+
return token_endpoint
107+
108+
def _is_expired(self) -> bool:
109+
if not self.expires_at:
110+
return True
111+
expiration_threshold = self.expires_at - self.leeway
112+
return expiration_threshold < self.is_expired_get_time()
113+
114+
def _requests_auth(self, r: "PreparedRequest") -> "PreparedRequest":
115+
r.headers["Authorization"] = "Bearer " + self.access_token
116+
return r
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class AuthBaseException(Exception):
2+
pass
3+
4+
5+
class AuthDiscoveryException(AuthBaseException):
6+
pass
7+
8+
9+
class AuthFetchTokenException(AuthBaseException):
10+
pass
11+
12+
13+
class AuthInvalidCredentialsException(AuthFetchTokenException):
14+
pass

0 commit comments

Comments
 (0)