|
3 | 3 | import hashlib |
4 | 4 | import hmac |
5 | 5 | import json |
| 6 | +import time |
6 | 7 | from typing import Dict, Optional, Union, List |
7 | 8 |
|
8 | 9 | import requests |
@@ -121,13 +122,35 @@ def post_and_get_stream(self, path: str, body: Dict = None, files: Dict = None, |
121 | 122 | """ |
122 | 123 | return self._request_stream('POST', path, body, files, headers) |
123 | 124 |
|
| 125 | + def _is_token_expired(self, buffer_seconds: int = 5) -> bool: |
| 126 | + """Check if the current JWT token is expired or about to expire. |
| 127 | +
|
| 128 | + :param buffer_seconds: Number of seconds before actual expiry to consider the token expired. |
| 129 | + :return: True if the token is expired or will expire within the buffer period. |
| 130 | + """ |
| 131 | + if not self._token: |
| 132 | + return True |
| 133 | + try: |
| 134 | + parts = self._token.split('.') |
| 135 | + if len(parts) != 3: |
| 136 | + return True |
| 137 | + padded_encoded_payload = parts[1] + '=' * (-len(parts[1]) % 4) # Pad base64 string if necessary |
| 138 | + payload = json.loads(base64.urlsafe_b64decode(padded_encoded_payload)) |
| 139 | + exp = payload.get('exp') |
| 140 | + if not isinstance(exp, (int, float)): |
| 141 | + return True |
| 142 | + return exp <= time.time() + buffer_seconds |
| 143 | + except Exception: |
| 144 | + return True |
| 145 | + |
124 | 146 | def _request(self, method: str, path: str, body: Dict = None, files: Dict = None, headers: Dict = None, |
125 | 147 | retry_count: int = 0) -> Optional[Union[Dict, List, bytes]]: |
126 | 148 | """ |
127 | 149 | Execute an authenticated HTTP request with automatic token management. |
128 | 150 | """ |
129 | | - # Ensure we have a valid token |
130 | | - if self._token is None: |
| 151 | + # Ensure we have a valid, non-expired token |
| 152 | + if self._token is None or self._is_token_expired(): |
| 153 | + self._token = None |
131 | 154 | self._authenticate() |
132 | 155 |
|
133 | 156 | if not path.startswith('/'): |
@@ -174,14 +197,16 @@ def _request(self, method: str, path: str, body: Dict = None, files: Dict = None |
174 | 197 | # Handle 401 - token expired, refresh and retry once |
175 | 198 | if response.status_code == 401 and retry_count < 1: |
176 | 199 | self._token = None |
| 200 | + self._refresh_or_reauthenticate() |
177 | 201 | return self._request(method, path, body, files, headers, retry_count=retry_count + 1) |
178 | 202 |
|
179 | 203 | raise LaraApiError.from_response(response) |
180 | 204 |
|
181 | 205 | def _request_stream(self, method: str, path: str, body: Dict = None, files: Dict = None, headers: Dict = None, |
182 | 206 | retry_count: int = 0): |
183 | | - # Ensure we have a valid token |
184 | | - if self._token is None: |
| 207 | + # Ensure we have a valid, non-expired token |
| 208 | + if self._token is None or self._is_token_expired(): |
| 209 | + self._token = None |
185 | 210 | self._authenticate() |
186 | 211 |
|
187 | 212 | if not path.startswith('/'): |
@@ -209,6 +234,7 @@ def _request_stream(self, method: str, path: str, body: Dict = None, files: Dict |
209 | 234 | # Handle 401 - token expired, refresh and retry once |
210 | 235 | if response.status_code == 401 and retry_count < 1: |
211 | 236 | self._token = None |
| 237 | + self._refresh_or_reauthenticate() |
212 | 238 | yield from self._request_stream(method, path, body, files, headers, retry_count=retry_count + 1) |
213 | 239 | return |
214 | 240 |
|
@@ -239,19 +265,34 @@ def _authenticate(self) -> str: |
239 | 265 | if self._token is not None: |
240 | 266 | return self._token |
241 | 267 |
|
242 | | - if self._refresh_token is not None: |
243 | | - # Try to refresh first |
244 | | - self._refresh() |
245 | | - elif isinstance(self._auth, AuthToken): |
| 268 | + if isinstance(self._auth, AuthToken) and self._refresh_token is None: |
246 | 269 | self._token = self._auth.token |
247 | 270 | self._refresh_token = self._auth.refresh_token |
248 | | - elif isinstance(self._auth, AccessKey): |
249 | | - self._authenticate_with_access_key() |
250 | 271 | else: |
251 | | - raise ValueError(f'Invalid authentication type: {type(self._auth).__name__}') |
| 272 | + self._refresh_or_reauthenticate() |
252 | 273 |
|
253 | 274 | return self._token |
254 | 275 |
|
| 276 | + def _refresh_or_reauthenticate(self) -> None: |
| 277 | + """ |
| 278 | + Try to refresh the token. If refresh fails, fall back to AccessKey re-authentication. |
| 279 | + """ |
| 280 | + if self._refresh_token is not None: |
| 281 | + # Try to refresh first |
| 282 | + try: |
| 283 | + self._refresh() |
| 284 | + return |
| 285 | + except LaraApiError as e: |
| 286 | + self._refresh_token = None |
| 287 | + if not isinstance(self._auth, AccessKey): |
| 288 | + raise |
| 289 | + |
| 290 | + if isinstance(self._auth, AccessKey): |
| 291 | + self._authenticate_with_access_key() |
| 292 | + return |
| 293 | + |
| 294 | + raise ValueError(f'No authentication method available for token renewal') |
| 295 | + |
255 | 296 | def _authenticate_with_access_key(self) -> None: |
256 | 297 | """Authenticate using AccessKey with challenge-response.""" |
257 | 298 | path = '/v2/auth' |
|
0 commit comments