nextlabs-sdk 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nextlabs_sdk/__init__.py +35 -0
- nextlabs_sdk/_auth/__init__.py +2 -0
- nextlabs_sdk/_auth/_active_account/__init__.py +6 -0
- nextlabs_sdk/_auth/_active_account/_active_account.py +34 -0
- nextlabs_sdk/_auth/_active_account/_active_account_store.py +85 -0
- nextlabs_sdk/_auth/_cloudaz_auth.py +434 -0
- nextlabs_sdk/_auth/_pdp_auth.py +103 -0
- nextlabs_sdk/_auth/_refresh_token_policy.py +58 -0
- nextlabs_sdk/_auth/_static_token_auth.py +19 -0
- nextlabs_sdk/_auth/_token_cache/__init__.py +8 -0
- nextlabs_sdk/_auth/_token_cache/_cached_token.py +117 -0
- nextlabs_sdk/_auth/_token_cache/_file_token_cache.py +101 -0
- nextlabs_sdk/_auth/_token_cache/_null_token_cache.py +21 -0
- nextlabs_sdk/_auth/_token_cache/_token_cache.py +25 -0
- nextlabs_sdk/_cli/__init__.py +0 -0
- nextlabs_sdk/_cli/_account_menu.py +71 -0
- nextlabs_sdk/_cli/_account_preferences.py +40 -0
- nextlabs_sdk/_cli/_account_preferences_store.py +107 -0
- nextlabs_sdk/_cli/_account_resolver.py +82 -0
- nextlabs_sdk/_cli/_activity_logs_cmd.py +126 -0
- nextlabs_sdk/_cli/_app.py +137 -0
- nextlabs_sdk/_cli/_audit_logs_cmd.py +136 -0
- nextlabs_sdk/_cli/_auth_cmd.py +341 -0
- nextlabs_sdk/_cli/_binary_output.py +31 -0
- nextlabs_sdk/_cli/_bulk_ids.py +65 -0
- nextlabs_sdk/_cli/_cache_key.py +46 -0
- nextlabs_sdk/_cli/_client_factory.py +157 -0
- nextlabs_sdk/_cli/_component_types_cmd.py +245 -0
- nextlabs_sdk/_cli/_components_cmd.py +334 -0
- nextlabs_sdk/_cli/_context.py +23 -0
- nextlabs_sdk/_cli/_dashboard_cmd.py +151 -0
- nextlabs_sdk/_cli/_detail_renderers.py +49 -0
- nextlabs_sdk/_cli/_error_handler.py +100 -0
- nextlabs_sdk/_cli/_expiry_format.py +50 -0
- nextlabs_sdk/_cli/_logging_setup.py +27 -0
- nextlabs_sdk/_cli/_operators_cmd.py +64 -0
- nextlabs_sdk/_cli/_output.py +74 -0
- nextlabs_sdk/_cli/_output_format.py +18 -0
- nextlabs_sdk/_cli/_parsing.py +35 -0
- nextlabs_sdk/_cli/_payload_loader.py +85 -0
- nextlabs_sdk/_cli/_pdp_cmd.py +294 -0
- nextlabs_sdk/_cli/_policies_cmd.py +536 -0
- nextlabs_sdk/_cli/_reporter_audit_logs_cmd.py +39 -0
- nextlabs_sdk/_cli/_reports_cmd.py +388 -0
- nextlabs_sdk/_cli/_system_config_cmd.py +36 -0
- nextlabs_sdk/_cli/_tags_cmd.py +101 -0
- nextlabs_sdk/_cloudaz/__init__.py +109 -0
- nextlabs_sdk/_cloudaz/_activity_log_query_models.py +35 -0
- nextlabs_sdk/_cloudaz/_activity_logs_service.py +138 -0
- nextlabs_sdk/_cloudaz/_async_client.py +171 -0
- nextlabs_sdk/_cloudaz/_audit_log_models.py +55 -0
- nextlabs_sdk/_cloudaz/_audit_logs.py +99 -0
- nextlabs_sdk/_cloudaz/_client.py +176 -0
- nextlabs_sdk/_cloudaz/_component_models.py +221 -0
- nextlabs_sdk/_cloudaz/_component_search.py +328 -0
- nextlabs_sdk/_cloudaz/_component_type_models.py +125 -0
- nextlabs_sdk/_cloudaz/_component_type_search.py +237 -0
- nextlabs_sdk/_cloudaz/_component_types.py +136 -0
- nextlabs_sdk/_cloudaz/_components.py +175 -0
- nextlabs_sdk/_cloudaz/_dashboard.py +121 -0
- nextlabs_sdk/_cloudaz/_dashboard_models.py +70 -0
- nextlabs_sdk/_cloudaz/_models.py +30 -0
- nextlabs_sdk/_cloudaz/_operators.py +54 -0
- nextlabs_sdk/_cloudaz/_policies.py +379 -0
- nextlabs_sdk/_cloudaz/_policy_models.py +288 -0
- nextlabs_sdk/_cloudaz/_policy_search.py +284 -0
- nextlabs_sdk/_cloudaz/_report_models.py +252 -0
- nextlabs_sdk/_cloudaz/_reporter_audit_log_models.py +21 -0
- nextlabs_sdk/_cloudaz/_reporter_audit_logs.py +83 -0
- nextlabs_sdk/_cloudaz/_reports.py +578 -0
- nextlabs_sdk/_cloudaz/_response.py +102 -0
- nextlabs_sdk/_cloudaz/_search.py +216 -0
- nextlabs_sdk/_cloudaz/_system_config.py +30 -0
- nextlabs_sdk/_cloudaz/_system_config_models.py +20 -0
- nextlabs_sdk/_cloudaz/_tags.py +129 -0
- nextlabs_sdk/_config.py +18 -0
- nextlabs_sdk/_http_transport.py +288 -0
- nextlabs_sdk/_http_transport_logging.py +61 -0
- nextlabs_sdk/_http_transport_logging_async.py +35 -0
- nextlabs_sdk/_json_response.py +131 -0
- nextlabs_sdk/_logging.py +106 -0
- nextlabs_sdk/_pagination.py +93 -0
- nextlabs_sdk/_pdp/__init__.py +30 -0
- nextlabs_sdk/_pdp/_async_client.py +115 -0
- nextlabs_sdk/_pdp/_client.py +115 -0
- nextlabs_sdk/_pdp/_enums.py +20 -0
- nextlabs_sdk/_pdp/_json_serializer.py +309 -0
- nextlabs_sdk/_pdp/_request_models.py +73 -0
- nextlabs_sdk/_pdp/_response_decode.py +32 -0
- nextlabs_sdk/_pdp/_response_models.py +68 -0
- nextlabs_sdk/_pdp/_urns.py +28 -0
- nextlabs_sdk/_pdp/_xml_serializer.py +285 -0
- nextlabs_sdk/_retry_policy.py +83 -0
- nextlabs_sdk/_version.py +8 -0
- nextlabs_sdk/cloudaz/__init__.py +114 -0
- nextlabs_sdk/exceptions.py +132 -0
- nextlabs_sdk/pdp/__init__.py +33 -0
- nextlabs_sdk/py.typed +0 -0
- nextlabs_sdk-0.2.0.dist-info/METADATA +457 -0
- nextlabs_sdk-0.2.0.dist-info/RECORD +104 -0
- nextlabs_sdk-0.2.0.dist-info/WHEEL +5 -0
- nextlabs_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- nextlabs_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- nextlabs_sdk-0.2.0.dist-info/top_level.txt +1 -0
nextlabs_sdk/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""nextlabs-sdk."""
|
|
2
|
+
|
|
3
|
+
from nextlabs_sdk._auth._cloudaz_auth import CloudAzAuth as CloudAzAuth
|
|
4
|
+
from nextlabs_sdk._auth._pdp_auth import PdpAuth as PdpAuth
|
|
5
|
+
from nextlabs_sdk._auth._static_token_auth import StaticTokenAuth as StaticTokenAuth
|
|
6
|
+
from nextlabs_sdk._auth._token_cache import (
|
|
7
|
+
CachedToken as CachedToken,
|
|
8
|
+
)
|
|
9
|
+
from nextlabs_sdk._auth._token_cache import (
|
|
10
|
+
FileTokenCache as FileTokenCache,
|
|
11
|
+
)
|
|
12
|
+
from nextlabs_sdk._auth._token_cache import (
|
|
13
|
+
NullTokenCache as NullTokenCache,
|
|
14
|
+
)
|
|
15
|
+
from nextlabs_sdk._auth._token_cache import (
|
|
16
|
+
TokenCache as TokenCache,
|
|
17
|
+
)
|
|
18
|
+
from nextlabs_sdk._cloudaz._async_client import (
|
|
19
|
+
AsyncCloudAzClient as AsyncCloudAzClient,
|
|
20
|
+
)
|
|
21
|
+
from nextlabs_sdk._cloudaz._client import CloudAzClient as CloudAzClient
|
|
22
|
+
from nextlabs_sdk._config import HttpConfig as HttpConfig
|
|
23
|
+
from nextlabs_sdk._config import RetryConfig as RetryConfig
|
|
24
|
+
from nextlabs_sdk._http_transport import (
|
|
25
|
+
create_async_http_client as create_async_http_client,
|
|
26
|
+
)
|
|
27
|
+
from nextlabs_sdk._http_transport import (
|
|
28
|
+
create_http_client as create_http_client,
|
|
29
|
+
)
|
|
30
|
+
from nextlabs_sdk._pagination import AsyncPaginator as AsyncPaginator
|
|
31
|
+
from nextlabs_sdk._pagination import PageResult as PageResult
|
|
32
|
+
from nextlabs_sdk._pagination import SyncPaginator as SyncPaginator
|
|
33
|
+
from nextlabs_sdk._pdp._async_client import AsyncPdpClient as AsyncPdpClient
|
|
34
|
+
from nextlabs_sdk._pdp._client import PdpClient as PdpClient
|
|
35
|
+
from nextlabs_sdk._version import __version__ as __version__
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class ActiveAccount:
|
|
8
|
+
"""Identifiers of the cached account currently marked as active."""
|
|
9
|
+
|
|
10
|
+
base_url: str
|
|
11
|
+
username: str
|
|
12
|
+
client_id: str
|
|
13
|
+
|
|
14
|
+
def to_dict(self) -> dict[str, str]:
|
|
15
|
+
return {
|
|
16
|
+
"base_url": self.base_url,
|
|
17
|
+
"username": self.username,
|
|
18
|
+
"client_id": self.client_id,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_dict(cls, payload: dict[str, object]) -> ActiveAccount:
|
|
23
|
+
base_url = payload["base_url"]
|
|
24
|
+
username = payload["username"]
|
|
25
|
+
client_id = payload["client_id"]
|
|
26
|
+
if not (
|
|
27
|
+
isinstance(base_url, str)
|
|
28
|
+
and isinstance(username, str)
|
|
29
|
+
and isinstance(client_id, str)
|
|
30
|
+
):
|
|
31
|
+
raise ValueError("ActiveAccount fields must be strings")
|
|
32
|
+
if not (base_url and username and client_id):
|
|
33
|
+
raise ValueError("ActiveAccount fields must be non-empty")
|
|
34
|
+
return cls(base_url=base_url, username=username, client_id=client_id)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from nextlabs_sdk._auth._active_account._active_account import ActiveAccount
|
|
9
|
+
|
|
10
|
+
_FILE_MODE = 0o600
|
|
11
|
+
_DIR_MODE = 0o700
|
|
12
|
+
_FILENAME = "active_account.json"
|
|
13
|
+
_PACKAGE_DIR = "nextlabs-sdk"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _default_path() -> Path:
|
|
17
|
+
override = os.environ.get("NEXTLABS_CACHE_DIR")
|
|
18
|
+
if override:
|
|
19
|
+
return Path(override) / _FILENAME
|
|
20
|
+
|
|
21
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
22
|
+
if xdg:
|
|
23
|
+
return Path(xdg) / _PACKAGE_DIR / _FILENAME
|
|
24
|
+
|
|
25
|
+
return Path.home() / ".cache" / _PACKAGE_DIR / _FILENAME
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ActiveAccountStore:
|
|
29
|
+
"""JSON-backed pointer to the currently active cached account."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, path: Path | str | None = None) -> None:
|
|
32
|
+
self._path = _default_path() if path is None else Path(path)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def path(self) -> Path:
|
|
36
|
+
return self._path
|
|
37
|
+
|
|
38
|
+
def load(self) -> ActiveAccount | None:
|
|
39
|
+
if not self._path.exists():
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
with self._path.open("r", encoding="utf-8") as fh:
|
|
43
|
+
loaded = json.load(fh)
|
|
44
|
+
except (OSError, json.JSONDecodeError):
|
|
45
|
+
return None
|
|
46
|
+
if not isinstance(loaded, dict):
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
return ActiveAccount.from_dict(loaded)
|
|
50
|
+
except (KeyError, TypeError, ValueError):
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def save(self, account: ActiveAccount) -> None:
|
|
54
|
+
directory = self._path.parent
|
|
55
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
os.chmod(directory, _DIR_MODE)
|
|
57
|
+
|
|
58
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
59
|
+
prefix=".active-",
|
|
60
|
+
suffix=".tmp",
|
|
61
|
+
dir=str(directory),
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
self._atomic_write(fd, tmp_name, account)
|
|
65
|
+
except Exception:
|
|
66
|
+
if os.path.exists(tmp_name):
|
|
67
|
+
os.unlink(tmp_name)
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
def clear(self) -> None:
|
|
71
|
+
try:
|
|
72
|
+
self._path.unlink()
|
|
73
|
+
except FileNotFoundError:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
def _atomic_write(
|
|
77
|
+
self,
|
|
78
|
+
fd: int,
|
|
79
|
+
tmp_name: str,
|
|
80
|
+
account: ActiveAccount,
|
|
81
|
+
) -> None:
|
|
82
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
83
|
+
json.dump(account.to_dict(), fh)
|
|
84
|
+
os.chmod(tmp_name, _FILE_MODE)
|
|
85
|
+
os.replace(tmp_name, self._path)
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Awaitable, Generator
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from nextlabs_sdk._auth._refresh_token_policy import RefreshDecision, decide
|
|
11
|
+
from nextlabs_sdk._auth._token_cache._cached_token import CachedToken
|
|
12
|
+
from nextlabs_sdk._auth._token_cache._null_token_cache import NullTokenCache
|
|
13
|
+
from nextlabs_sdk._auth._token_cache._token_cache import TokenCache
|
|
14
|
+
from nextlabs_sdk._json_response import decode_json_object, require_int, require_str
|
|
15
|
+
from nextlabs_sdk._logging import logger
|
|
16
|
+
from nextlabs_sdk.exceptions import AuthenticationError, RefreshTokenExpiredError
|
|
17
|
+
|
|
18
|
+
_EXPIRY_SAFETY_MARGIN = 60
|
|
19
|
+
_OK_STATUS = 200
|
|
20
|
+
_UNAUTHORIZED_STATUS = 401
|
|
21
|
+
_REDIRECT_MIN_STATUS = 300
|
|
22
|
+
_REDIRECT_MAX_STATUS = 399
|
|
23
|
+
_SPA_FRAGMENT = "#"
|
|
24
|
+
_LOCATION_HEADER = "location"
|
|
25
|
+
_RELOGIN_HINT = "Run `nextlabs auth login` to re-authenticate."
|
|
26
|
+
_SPA_REDIRECT_MSG = (
|
|
27
|
+
"Server redirected API call to SPA login page "
|
|
28
|
+
"(Location={location!r}) — access token was rejected. {hint}"
|
|
29
|
+
)
|
|
30
|
+
_HTTP_POST = "POST"
|
|
31
|
+
_FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
|
32
|
+
_INITIAL_EXPIRY_AT = float(0)
|
|
33
|
+
|
|
34
|
+
_MSG_LIFETIME_EXCEEDED = "Refresh token lifetime exceeded — re-login required. {hint}"
|
|
35
|
+
_MSG_SERVER_REJECTED = "Refresh token rejected by server — re-login required. {hint}"
|
|
36
|
+
_MSG_NO_CREDS = "No refresh token and no password available. {hint}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _spa_redirect_location(response: httpx.Response) -> str:
|
|
40
|
+
for hop in response.history:
|
|
41
|
+
location = hop.headers.get(_LOCATION_HEADER, "")
|
|
42
|
+
if _SPA_FRAGMENT in location:
|
|
43
|
+
return location
|
|
44
|
+
return response.headers.get(_LOCATION_HEADER, "")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_spa_redirect(response: httpx.Response) -> bool:
|
|
48
|
+
for hop in response.history:
|
|
49
|
+
if _SPA_FRAGMENT in hop.headers.get(_LOCATION_HEADER, ""):
|
|
50
|
+
return True
|
|
51
|
+
status = response.status_code
|
|
52
|
+
if _REDIRECT_MIN_STATUS <= status <= _REDIRECT_MAX_STATUS:
|
|
53
|
+
return _SPA_FRAGMENT in response.headers.get(_LOCATION_HEADER, "")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _form_headers() -> dict[str, str]:
|
|
58
|
+
return {"Content-Type": _FORM_CONTENT_TYPE}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _build_password_request(
|
|
62
|
+
*,
|
|
63
|
+
token_url: str,
|
|
64
|
+
username: str,
|
|
65
|
+
password: str | None,
|
|
66
|
+
client_id: str,
|
|
67
|
+
) -> httpx.Request:
|
|
68
|
+
return httpx.Request(
|
|
69
|
+
method=_HTTP_POST,
|
|
70
|
+
url=token_url,
|
|
71
|
+
data={
|
|
72
|
+
"grant_type": "password",
|
|
73
|
+
"username": username,
|
|
74
|
+
"password": password,
|
|
75
|
+
"client_id": client_id,
|
|
76
|
+
},
|
|
77
|
+
headers=_form_headers(),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_refresh_request(
|
|
82
|
+
*,
|
|
83
|
+
token_url: str,
|
|
84
|
+
refresh_token: str,
|
|
85
|
+
client_id: str,
|
|
86
|
+
) -> httpx.Request:
|
|
87
|
+
return httpx.Request(
|
|
88
|
+
method=_HTTP_POST,
|
|
89
|
+
url=token_url,
|
|
90
|
+
data={
|
|
91
|
+
"grant_type": "refresh_token",
|
|
92
|
+
"refresh_token": refresh_token,
|
|
93
|
+
"client_id": client_id,
|
|
94
|
+
},
|
|
95
|
+
headers=_form_headers(),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _raise_unless_password_available(
|
|
100
|
+
*,
|
|
101
|
+
password: str | None,
|
|
102
|
+
token_url: str,
|
|
103
|
+
message: str,
|
|
104
|
+
exc_cls: type[AuthenticationError],
|
|
105
|
+
warn: str | None,
|
|
106
|
+
) -> None:
|
|
107
|
+
if password is not None:
|
|
108
|
+
return
|
|
109
|
+
if warn is not None:
|
|
110
|
+
logger.warning(warn)
|
|
111
|
+
raise exc_cls(
|
|
112
|
+
message.format(hint=_RELOGIN_HINT),
|
|
113
|
+
status_code=None,
|
|
114
|
+
response_body=None,
|
|
115
|
+
request_method=_HTTP_POST,
|
|
116
|
+
request_url=token_url,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _handle_refresh_failure(
|
|
121
|
+
*,
|
|
122
|
+
decision: RefreshDecision,
|
|
123
|
+
password: str | None,
|
|
124
|
+
token_url: str,
|
|
125
|
+
) -> None:
|
|
126
|
+
if decision is RefreshDecision.USE_REFRESH:
|
|
127
|
+
_raise_unless_password_available(
|
|
128
|
+
password=password,
|
|
129
|
+
token_url=token_url,
|
|
130
|
+
message=_MSG_SERVER_REJECTED,
|
|
131
|
+
exc_cls=RefreshTokenExpiredError,
|
|
132
|
+
warn="cloudaz auth: refresh token rejected by server"
|
|
133
|
+
" and no password available — re-login required",
|
|
134
|
+
)
|
|
135
|
+
elif decision is RefreshDecision.KNOWN_EXPIRED:
|
|
136
|
+
logger.debug("cloudaz auth: refresh skipped (known-expired)")
|
|
137
|
+
_raise_unless_password_available(
|
|
138
|
+
password=password,
|
|
139
|
+
token_url=token_url,
|
|
140
|
+
message=_MSG_LIFETIME_EXCEEDED,
|
|
141
|
+
exc_cls=RefreshTokenExpiredError,
|
|
142
|
+
warn="cloudaz auth: refresh token expired"
|
|
143
|
+
" and no password available — re-login required",
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
_raise_unless_password_available(
|
|
147
|
+
password=password,
|
|
148
|
+
token_url=token_url,
|
|
149
|
+
message=_MSG_NO_CREDS,
|
|
150
|
+
exc_cls=AuthenticationError,
|
|
151
|
+
warn=None,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class CloudAzAuth(httpx.Auth):
|
|
156
|
+
"""OIDC password grant auth for the CloudAz Console API.
|
|
157
|
+
|
|
158
|
+
Supports an optional pluggable :class:`TokenCache` backend. Expiry is
|
|
159
|
+
tracked as absolute UTC epoch seconds so that cached tokens survive
|
|
160
|
+
process restarts.
|
|
161
|
+
|
|
162
|
+
When ``refresh_token_lifetime`` is provided, the SDK records the
|
|
163
|
+
refresh token's absolute expiry at every successful token
|
|
164
|
+
acquisition and uses it to short-circuit re-auth once the lifetime
|
|
165
|
+
has elapsed — skipping a doomed HTTP round-trip and surfacing a
|
|
166
|
+
:class:`RefreshTokenExpiredError` (or falling back to the password
|
|
167
|
+
grant when a password is configured).
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
requires_request_body = False
|
|
171
|
+
requires_response_body = True
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
token_url: str,
|
|
176
|
+
username: str,
|
|
177
|
+
password: str | None,
|
|
178
|
+
client_id: str,
|
|
179
|
+
*,
|
|
180
|
+
token_cache: TokenCache | None = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
self._token_url = token_url
|
|
183
|
+
self._username = username
|
|
184
|
+
self._password = password
|
|
185
|
+
self._client_id = client_id
|
|
186
|
+
self._cache: TokenCache = token_cache or NullTokenCache()
|
|
187
|
+
self._cache_key = f"{token_url}|{username}|{client_id}"
|
|
188
|
+
self._lock = threading.Lock()
|
|
189
|
+
self.refresh_token_lifetime: int | None = None
|
|
190
|
+
|
|
191
|
+
self._token: str | None = None
|
|
192
|
+
self._refresh_token: str | None = None
|
|
193
|
+
self._refresh_expires_at: float | None = None
|
|
194
|
+
self._expires_at: float = _INITIAL_EXPIRY_AT
|
|
195
|
+
|
|
196
|
+
cached = self._cache.load(self._cache_key)
|
|
197
|
+
if cached is not None:
|
|
198
|
+
self._refresh_token = cached.refresh_token
|
|
199
|
+
self._refresh_expires_at = cached.refresh_expires_at
|
|
200
|
+
if not cached.is_expired(
|
|
201
|
+
now=time.time(),
|
|
202
|
+
safety_margin=_EXPIRY_SAFETY_MARGIN,
|
|
203
|
+
):
|
|
204
|
+
self._token = cached.access_token
|
|
205
|
+
self._expires_at = cached.expires_at
|
|
206
|
+
|
|
207
|
+
def auth_flow(
|
|
208
|
+
self,
|
|
209
|
+
request: httpx.Request,
|
|
210
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
211
|
+
if not self._has_valid_token():
|
|
212
|
+
yield from self._reauthenticate()
|
|
213
|
+
|
|
214
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
215
|
+
response = yield request
|
|
216
|
+
|
|
217
|
+
if response.status_code == _UNAUTHORIZED_STATUS or _is_spa_redirect(response):
|
|
218
|
+
yield from self._reauthenticate()
|
|
219
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
220
|
+
retried = yield request
|
|
221
|
+
if _is_spa_redirect(retried):
|
|
222
|
+
raise AuthenticationError(
|
|
223
|
+
_SPA_REDIRECT_MSG.format(
|
|
224
|
+
location=_spa_redirect_location(retried),
|
|
225
|
+
hint=_RELOGIN_HINT,
|
|
226
|
+
),
|
|
227
|
+
status_code=retried.status_code,
|
|
228
|
+
response_body=None,
|
|
229
|
+
request_method=request.method,
|
|
230
|
+
request_url=str(request.url),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def ensure_token(
|
|
234
|
+
self,
|
|
235
|
+
send: Callable[[httpx.Request], httpx.Response],
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Fetch and cache a token synchronously via a provided transport.
|
|
238
|
+
|
|
239
|
+
Intended for explicit `authenticate()` flows that need to acquire a
|
|
240
|
+
token without making a password HTTP call. No-op when a valid token
|
|
241
|
+
is already available in memory.
|
|
242
|
+
"""
|
|
243
|
+
if self._has_valid_token():
|
|
244
|
+
return
|
|
245
|
+
if self._try_refresh_sync(send):
|
|
246
|
+
return
|
|
247
|
+
logger.debug("cloudaz auth: falling back to password grant")
|
|
248
|
+
response = send(
|
|
249
|
+
_build_password_request(
|
|
250
|
+
token_url=self._token_url,
|
|
251
|
+
username=self._username,
|
|
252
|
+
password=self._password,
|
|
253
|
+
client_id=self._client_id,
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
self._parse_token_response(response)
|
|
257
|
+
|
|
258
|
+
async def ensure_token_async(
|
|
259
|
+
self,
|
|
260
|
+
send: Callable[[httpx.Request], Awaitable[httpx.Response]],
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Async counterpart of :meth:`ensure_token`."""
|
|
263
|
+
if self._has_valid_token():
|
|
264
|
+
return
|
|
265
|
+
if await self._try_refresh_async(send):
|
|
266
|
+
return
|
|
267
|
+
logger.debug("cloudaz auth: falling back to password grant")
|
|
268
|
+
response = await send(
|
|
269
|
+
_build_password_request(
|
|
270
|
+
token_url=self._token_url,
|
|
271
|
+
username=self._username,
|
|
272
|
+
password=self._password,
|
|
273
|
+
client_id=self._client_id,
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
self._parse_token_response(response)
|
|
277
|
+
|
|
278
|
+
def _refresh_decision(self) -> RefreshDecision:
|
|
279
|
+
if self._refresh_token is None:
|
|
280
|
+
return RefreshDecision.ABSENT
|
|
281
|
+
return decide(
|
|
282
|
+
refresh_token=self._refresh_token,
|
|
283
|
+
refresh_expires_at=self._refresh_expires_at,
|
|
284
|
+
now=time.time(),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _has_valid_token(self) -> bool:
|
|
288
|
+
with self._lock:
|
|
289
|
+
return self._token is not None and time.time() < self._expires_at
|
|
290
|
+
|
|
291
|
+
def _reauthenticate(self) -> Generator[httpx.Request, httpx.Response, None]:
|
|
292
|
+
decision = self._refresh_decision()
|
|
293
|
+
if decision is RefreshDecision.USE_REFRESH:
|
|
294
|
+
logger.debug("cloudaz auth: refresh attempt starting")
|
|
295
|
+
assert self._refresh_token is not None
|
|
296
|
+
response = yield _build_refresh_request(
|
|
297
|
+
token_url=self._token_url,
|
|
298
|
+
refresh_token=self._refresh_token,
|
|
299
|
+
client_id=self._client_id,
|
|
300
|
+
)
|
|
301
|
+
if response.status_code == _OK_STATUS:
|
|
302
|
+
self._parse_token_response(response)
|
|
303
|
+
logger.debug("cloudaz auth: refresh succeeded")
|
|
304
|
+
return
|
|
305
|
+
_handle_refresh_failure(
|
|
306
|
+
decision=decision, password=self._password, token_url=self._token_url
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
_handle_refresh_failure(
|
|
310
|
+
decision=decision, password=self._password, token_url=self._token_url
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
logger.debug("cloudaz auth: falling back to password grant")
|
|
314
|
+
response = yield _build_password_request(
|
|
315
|
+
token_url=self._token_url,
|
|
316
|
+
username=self._username,
|
|
317
|
+
password=self._password,
|
|
318
|
+
client_id=self._client_id,
|
|
319
|
+
)
|
|
320
|
+
self._parse_token_response(response)
|
|
321
|
+
|
|
322
|
+
def _try_refresh_sync(
|
|
323
|
+
self,
|
|
324
|
+
send: Callable[[httpx.Request], httpx.Response],
|
|
325
|
+
) -> bool:
|
|
326
|
+
decision = self._refresh_decision()
|
|
327
|
+
if decision is RefreshDecision.USE_REFRESH:
|
|
328
|
+
logger.debug("cloudaz auth: refresh attempt starting")
|
|
329
|
+
assert self._refresh_token is not None
|
|
330
|
+
response = send(
|
|
331
|
+
_build_refresh_request(
|
|
332
|
+
token_url=self._token_url,
|
|
333
|
+
refresh_token=self._refresh_token,
|
|
334
|
+
client_id=self._client_id,
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
if response.status_code == _OK_STATUS:
|
|
338
|
+
self._parse_token_response(response)
|
|
339
|
+
logger.debug("cloudaz auth: refresh succeeded")
|
|
340
|
+
return True
|
|
341
|
+
_handle_refresh_failure(
|
|
342
|
+
decision=decision, password=self._password, token_url=self._token_url
|
|
343
|
+
)
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
async def _try_refresh_async(
|
|
347
|
+
self,
|
|
348
|
+
send: Callable[[httpx.Request], Awaitable[httpx.Response]],
|
|
349
|
+
) -> bool:
|
|
350
|
+
decision = self._refresh_decision()
|
|
351
|
+
if decision is RefreshDecision.USE_REFRESH:
|
|
352
|
+
logger.debug("cloudaz auth: refresh attempt starting")
|
|
353
|
+
assert self._refresh_token is not None
|
|
354
|
+
response = await send(
|
|
355
|
+
_build_refresh_request(
|
|
356
|
+
token_url=self._token_url,
|
|
357
|
+
refresh_token=self._refresh_token,
|
|
358
|
+
client_id=self._client_id,
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
if response.status_code == _OK_STATUS:
|
|
362
|
+
self._parse_token_response(response)
|
|
363
|
+
logger.debug("cloudaz auth: refresh succeeded")
|
|
364
|
+
return True
|
|
365
|
+
_handle_refresh_failure(
|
|
366
|
+
decision=decision, password=self._password, token_url=self._token_url
|
|
367
|
+
)
|
|
368
|
+
return False
|
|
369
|
+
|
|
370
|
+
def _parse_token_response(
|
|
371
|
+
self,
|
|
372
|
+
response: httpx.Response,
|
|
373
|
+
) -> None:
|
|
374
|
+
if response.status_code != _OK_STATUS:
|
|
375
|
+
raise AuthenticationError(
|
|
376
|
+
f"Token acquisition failed: HTTP {response.status_code}",
|
|
377
|
+
status_code=response.status_code,
|
|
378
|
+
response_body=response.text,
|
|
379
|
+
request_method=_HTTP_POST,
|
|
380
|
+
request_url=self._token_url,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
body = decode_json_object(
|
|
384
|
+
response,
|
|
385
|
+
error_cls=AuthenticationError,
|
|
386
|
+
context=" in token response",
|
|
387
|
+
)
|
|
388
|
+
expires_in = require_int(
|
|
389
|
+
body,
|
|
390
|
+
"expires_in",
|
|
391
|
+
error_cls=AuthenticationError,
|
|
392
|
+
context=" in token response",
|
|
393
|
+
)
|
|
394
|
+
now = time.time()
|
|
395
|
+
expires_at = now + expires_in - _EXPIRY_SAFETY_MARGIN
|
|
396
|
+
access_token = require_str(
|
|
397
|
+
body,
|
|
398
|
+
"access_token",
|
|
399
|
+
error_cls=AuthenticationError,
|
|
400
|
+
context=" in token response",
|
|
401
|
+
)
|
|
402
|
+
refresh_token_raw = body.get("refresh_token")
|
|
403
|
+
refresh_token = (
|
|
404
|
+
refresh_token_raw
|
|
405
|
+
if isinstance(refresh_token_raw, str)
|
|
406
|
+
else self._refresh_token
|
|
407
|
+
)
|
|
408
|
+
token_type_raw = body.get("token_type", "bearer")
|
|
409
|
+
token_type = token_type_raw if isinstance(token_type_raw, str) else "bearer"
|
|
410
|
+
scope_raw = body.get("scope")
|
|
411
|
+
scope = scope_raw if isinstance(scope_raw, str) else None
|
|
412
|
+
refresh_expires_at = (
|
|
413
|
+
None
|
|
414
|
+
if self.refresh_token_lifetime is None
|
|
415
|
+
else now + self.refresh_token_lifetime
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
with self._lock:
|
|
419
|
+
self._token = access_token
|
|
420
|
+
self._refresh_token = refresh_token
|
|
421
|
+
self._expires_at = expires_at
|
|
422
|
+
self._refresh_expires_at = refresh_expires_at
|
|
423
|
+
|
|
424
|
+
self._cache.save(
|
|
425
|
+
self._cache_key,
|
|
426
|
+
CachedToken(
|
|
427
|
+
access_token=access_token,
|
|
428
|
+
refresh_token=refresh_token,
|
|
429
|
+
expires_at=expires_at,
|
|
430
|
+
token_type=token_type,
|
|
431
|
+
scope=scope,
|
|
432
|
+
refresh_expires_at=refresh_expires_at,
|
|
433
|
+
),
|
|
434
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Generator
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from nextlabs_sdk._json_response import decode_json_object, require_int, require_str
|
|
10
|
+
from nextlabs_sdk.exceptions import AuthenticationError
|
|
11
|
+
|
|
12
|
+
_EXPIRY_SAFETY_MARGIN = 60
|
|
13
|
+
_OK_STATUS = 200
|
|
14
|
+
_UNAUTHORIZED_STATUS = 401
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PdpAuth(httpx.Auth):
|
|
18
|
+
"""OAuth2 client credentials auth for the PDP REST API."""
|
|
19
|
+
|
|
20
|
+
requires_request_body = False
|
|
21
|
+
requires_response_body = True
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
token_url: str,
|
|
26
|
+
client_id: str,
|
|
27
|
+
client_secret: str,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._token_url = token_url
|
|
30
|
+
self._client_id = client_id
|
|
31
|
+
self._client_secret = client_secret
|
|
32
|
+
self._token: str | None = None
|
|
33
|
+
self._token_expiry: float = 0
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
def auth_flow(
|
|
37
|
+
self,
|
|
38
|
+
request: httpx.Request,
|
|
39
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
40
|
+
if not self._has_valid_token():
|
|
41
|
+
token_response = yield self._build_token_request()
|
|
42
|
+
self._parse_token_response(token_response)
|
|
43
|
+
|
|
44
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
45
|
+
response = yield request
|
|
46
|
+
|
|
47
|
+
if response.status_code == _UNAUTHORIZED_STATUS:
|
|
48
|
+
token_response = yield self._build_token_request()
|
|
49
|
+
self._parse_token_response(token_response)
|
|
50
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
51
|
+
yield request
|
|
52
|
+
|
|
53
|
+
def _has_valid_token(self) -> bool:
|
|
54
|
+
with self._lock:
|
|
55
|
+
return self._token is not None and time.monotonic() < self._token_expiry
|
|
56
|
+
|
|
57
|
+
def _build_token_request(self) -> httpx.Request:
|
|
58
|
+
return httpx.Request(
|
|
59
|
+
method="POST",
|
|
60
|
+
url=self._token_url,
|
|
61
|
+
data={
|
|
62
|
+
"grant_type": "client_credentials",
|
|
63
|
+
"client_id": self._client_id,
|
|
64
|
+
"client_secret": self._client_secret,
|
|
65
|
+
},
|
|
66
|
+
headers={
|
|
67
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _parse_token_response(
|
|
72
|
+
self,
|
|
73
|
+
response: httpx.Response,
|
|
74
|
+
) -> None:
|
|
75
|
+
if response.status_code != _OK_STATUS:
|
|
76
|
+
raise AuthenticationError(
|
|
77
|
+
f"Token acquisition failed: HTTP {response.status_code}",
|
|
78
|
+
status_code=response.status_code,
|
|
79
|
+
response_body=response.text,
|
|
80
|
+
request_method="POST",
|
|
81
|
+
request_url=self._token_url,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
body = decode_json_object(
|
|
85
|
+
response,
|
|
86
|
+
error_cls=AuthenticationError,
|
|
87
|
+
context=" in token response",
|
|
88
|
+
)
|
|
89
|
+
access_token = require_str(
|
|
90
|
+
body,
|
|
91
|
+
"access_token",
|
|
92
|
+
error_cls=AuthenticationError,
|
|
93
|
+
context=" in token response",
|
|
94
|
+
)
|
|
95
|
+
expires_in = require_int(
|
|
96
|
+
body,
|
|
97
|
+
"expires_in",
|
|
98
|
+
error_cls=AuthenticationError,
|
|
99
|
+
context=" in token response",
|
|
100
|
+
)
|
|
101
|
+
with self._lock:
|
|
102
|
+
self._token = access_token
|
|
103
|
+
self._token_expiry = time.monotonic() + expires_in - _EXPIRY_SAFETY_MARGIN
|