earthscope-sdk 0.2.1__py3-none-any.whl → 1.0.0b0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -1 +1,5 @@
1
- __version__ = "0.2.1"
1
+ __version__ = "1.0.0b0"
2
+
3
+ from earthscope_sdk.client import AsyncEarthScopeClient, EarthScopeClient
4
+
5
+ __all__ = ["AsyncEarthScopeClient", "EarthScopeClient"]
@@ -1,441 +1,318 @@
1
1
  import datetime as dt
2
- import json
3
2
  import logging
3
+ from contextlib import suppress
4
+ from typing import Optional
4
5
 
5
- from abc import ABC, abstractmethod
6
- from dataclasses import dataclass
7
- from typing import Dict, Optional, Union
8
- import jwt
6
+ import httpx
7
+ from pydantic import ValidationError
9
8
 
10
- import requests
9
+ from earthscope_sdk.auth.error import (
10
+ InvalidRefreshTokenError,
11
+ NoAccessTokenError,
12
+ NoRefreshTokenError,
13
+ NoTokensError,
14
+ )
15
+ from earthscope_sdk.common.context import SdkContext
16
+ from earthscope_sdk.config.models import AccessTokenBody, Tokens
11
17
 
12
18
  logger = logging.getLogger(__name__)
13
19
 
14
20
 
15
- class AuthFlowError(Exception):
16
- """Generic authentication flow error"""
17
-
18
-
19
- class UnauthorizedError(AuthFlowError):
20
- pass
21
-
22
-
23
- class NoTokensError(AuthFlowError):
24
- pass
25
-
26
-
27
- class NoIdTokenError(NoTokensError):
28
- pass
29
-
30
-
31
- class NoRefreshTokenError(NoTokensError):
32
- pass
33
-
34
-
35
- class InvalidRefreshTokenError(AuthFlowError):
36
- pass
37
-
38
-
39
- @dataclass
40
- class TokensResponse:
41
- access_token: str
42
- refresh_token: Optional[str]
43
- id_token: Optional[str]
44
- token_type: str
45
- expires_in: int
46
- scope: Optional[str]
47
-
48
-
49
- @dataclass
50
- class UnvalidatedTokens:
51
- access_token: str
52
- id_token: Optional[str]
53
- refresh_token: Optional[str]
54
- scope: str
55
-
56
- @classmethod
57
- def from_dict(cls, o: Dict):
58
- return cls(
59
- access_token=o.get("access_token"),
60
- id_token=o.get("id_token"),
61
- refresh_token=o.get("refresh_token"),
62
- scope=o.get("scope"),
63
- )
64
-
65
-
66
- @dataclass
67
- class ValidTokens(UnvalidatedTokens):
68
- expires_at: int
69
- issued_at: int
70
-
71
- @classmethod
72
- def from_unvalidated(cls, creds: UnvalidatedTokens, body: Dict):
73
- return cls(
74
- access_token=creds.access_token,
75
- id_token=creds.id_token,
76
- refresh_token=creds.refresh_token,
77
- scope=creds.scope,
78
- expires_at=body.get("exp"),
79
- issued_at=body.get("iat"),
80
- )
81
-
82
-
83
- class AuthFlow(ABC):
21
+ class AuthFlow(httpx.Auth):
84
22
  """
85
- This is an abstract class that handles retrieving, validating, saving, and refreshing access tokens.
86
-
87
- Attributes
88
- __________
89
- domain : str
90
- Auth0 tenant domain URL or custom domain (default is 'login.earthscope.org')
91
- audience : str
92
- Auth0 API Identifier (default is 'https://account.earthscope.org')
93
- client_id : str
94
- Identification value of Auth0 Application (default is 'b9DtAFBd6QvMg761vI3YhYquNZbJX5G0')
95
- scope : str
96
- The specific actions Auth0 applications can be allowed to do or information that they can request on a user’s behalf.
97
- _tokens : str, optional
98
- Access or ID Token
99
- _access_token_body : dict, optional
100
- Body of the decrypted access token
101
- _id_token_body : dict, optional
102
- Body of the decrypted id token
103
-
104
- Methods
105
- _______
106
- load_tokens
107
- Load the token (abstract method)
108
- save_tokens(creds)
109
- Save the token (abstract method)
110
- refresh(scope=None, revoke=False)
111
- Refresh the access token or revoke the refresh token
112
- validate_and_save_tokens(unvalidated_creds)
113
- Validate and save the token
114
- validate_tokens(unvalidated_creds)
115
- Validate the token
116
- get_access_token_refresh_if_necessary(no_auto_refresh=False, auto_refresh_threshold=3600)
117
- Retrieve the access token. Automatically refreshes the token when necessary.
118
-
23
+ Generic oauth2 flow class for handling retrieving, validating, saving, and refreshing access tokens.
119
24
  """
25
+
120
26
  @property
121
27
  def access_token(self):
122
28
  """
123
- Get access token
124
-
125
- Returns
126
- -------
127
- access token: str
128
- """
129
- return self.tokens.access_token
29
+ Access token
130
30
 
131
- @property
132
- def access_token_body(self):
31
+ Raises:
32
+ NoTokensError: no tokens (at all) are present
33
+ NoAccessTokenError: no access token is present
133
34
  """
134
- Body of the access token
35
+ if at := self.tokens.access_token:
36
+ return at.get_secret_value()
135
37
 
136
- Raises
137
- ______
138
- NoTokensError
139
- If there is no token
140
-
141
- Returns
142
- -------
143
- access_token_body: dict
144
- """
145
- if self._access_token_body is None:
146
- raise NoTokensError
147
- return self._access_token_body
38
+ raise NoAccessTokenError("No access token was found. Please re-authenticate.")
148
39
 
149
40
  @property
150
- def tokens(self):
41
+ def access_token_body(self):
151
42
  """
152
- Get tokens
43
+ Access token body
153
44
 
154
- raises
155
- ------
156
- NoTokensError
157
- If no token to retrieve
158
-
159
- Returns
160
- -------
161
- tokens
45
+ Raises:
46
+ NoAccessTokenError: no access token is present
162
47
  """
163
- if not self._tokens:
164
- raise NoTokensError
48
+ if body := self._access_token_body:
49
+ return body
165
50
 
166
- return self._tokens
51
+ raise NoAccessTokenError("No access token was found. Please re-authenticate.")
167
52
 
168
53
  @property
169
54
  def expires_at(self):
170
55
  """
171
- When the token expires
56
+ Time of access token expiration
172
57
 
173
- Returns
174
- -------
175
- datetime
58
+ Raises:
59
+ NoAccessTokenError: no access token is present
176
60
  """
177
- return dt.datetime.fromtimestamp(self.tokens.expires_at, dt.timezone.utc)
61
+ return self.access_token_body.expires_at
178
62
 
179
63
  @property
180
- def id_token(self):
64
+ def has_refresh_token(self):
181
65
  """
182
- Get the ID token
183
-
184
- Raises
185
- ------
186
- NoIDTokenError
187
- If there is no id token to be retrieved
188
-
189
- Returns
190
- -------
191
- id token: srt
66
+ Whether or not we have a refresh token
192
67
  """
193
- idt = self.tokens.id_token
194
- if not idt:
195
- raise NoIdTokenError
196
-
197
- return idt
68
+ try:
69
+ return self.refresh_token is not None
70
+ except NoTokensError:
71
+ return False
198
72
 
199
73
  @property
200
- def id_token_body(self):
74
+ def issued_at(self):
201
75
  """
202
- Body of the id token
203
-
204
- Raises
205
- ______
206
- NoIdTokenError
207
- If there is no id token
76
+ Time of access token creation
208
77
 
209
- Returns
210
- -------
211
- id_token_body: dict
78
+ Raises:
79
+ NoAccessTokenError: no access token is present
212
80
  """
213
- if self._id_token_body is None:
214
- raise NoIdTokenError
215
- return self._id_token_body
81
+ return self.access_token_body.issued_at
216
82
 
217
83
  @property
218
- def issued_at(self):
84
+ def refresh_token(self):
219
85
  """
220
- When the token was issued
86
+ Refresh token
221
87
 
222
- Returns
223
- -------
224
- datetime
88
+ Raises:
89
+ NoTokensError: no tokens (at all) are present
90
+ NoRefreshTokenError: no refresh token is present
225
91
  """
226
- return dt.datetime.fromtimestamp(self.tokens.issued_at, dt.timezone.utc)
92
+ if rt := self.tokens.refresh_token:
93
+ return rt.get_secret_value()
94
+
95
+ raise NoRefreshTokenError("No refresh token was found. Please re-authenticate.")
227
96
 
228
97
  @property
229
- def refresh_token(self):
98
+ def scope(self):
230
99
  """
231
- Get the refresh token
232
-
233
- Raises
234
- ------
235
- NoRefreshTokenError
236
- If there is no refresh token to be retrieved
100
+ Access token scope
237
101
 
238
- Returns
239
- -------
240
- refresh token: srt
102
+ Raises:
103
+ NoAccessTokenError: no access token is present
241
104
  """
242
- rt = self.tokens.refresh_token
243
- if not rt:
244
- raise NoRefreshTokenError
245
-
246
- return rt
105
+ return set(self.access_token_body.scope.split())
247
106
 
248
107
  @property
249
- def has_refresh_token(self):
108
+ def tokens(self):
250
109
  """
251
- If the acces token contains a refresh token
110
+ Oauth2 tokens managed by this auth flow
252
111
 
253
- Returns
254
- -------
255
- boolean
112
+ Raises:
113
+ NoTokensError: no tokens (at all) are present
256
114
  """
257
- try:
258
- return self.tokens.refresh_token is not None
259
- except NoTokensError:
260
- return False
115
+ if tokens := self._tokens:
116
+ return tokens
117
+
118
+ raise NoTokensError("No tokens were found. Please re-authenticate.")
261
119
 
262
120
  @property
263
121
  def ttl(self):
264
122
  """
265
- Time to live of the token
123
+ Access token time-to-live (ttl) before expiration
266
124
 
267
- Returns
268
- -------
269
- datetime
125
+ Raises:
126
+ NoAccessTokenError: no access token is present
270
127
  """
271
- return self.expires_at - dt.datetime.now(dt.timezone.utc)
128
+ return self.access_token_body.ttl
129
+
130
+ def __init__(self, ctx: SdkContext) -> None:
131
+ self._ctx = ctx
132
+ self._settings = ctx.settings.oauth2
133
+
134
+ # Local state
135
+ self._access_token_body: Optional[AccessTokenBody] = None
136
+ self._tokens: Optional[Tokens] = None
272
137
 
273
- def __init__(self, domain: str, audience: str, client_id: str, scope: str) -> None:
274
- self.auth0_domain = domain
275
- self.auth0_audience = audience
276
- self.auth0_client_id = client_id
277
- self.token_scope = scope
138
+ # httpx.Auth objects can be used in either sync or async clients so we
139
+ # facilitate both from the same class
140
+ self.refresh = ctx.syncify(self.async_refresh)
141
+ self.refresh_if_necessary = ctx.syncify(self.async_refresh_if_necessary)
142
+ self.revoke_refresh_token = ctx.syncify(self.async_revoke_refresh_token)
278
143
 
279
- self._tokens: Optional[ValidTokens] = None
280
- self._access_token_body: Optional[Dict[str, Union[str, int]]] = None
281
- self._id_token_body: Optional[Dict[str, Union[str, int]]] = None
144
+ # Initialize with provided tokens
145
+ with suppress(ValidationError):
146
+ self._validate_tokens(self._settings)
282
147
 
283
- @abstractmethod
284
- def load_tokens(self):
148
+ async def async_refresh(self, scope: Optional[str] = None):
285
149
  """
286
- Load the token (abstract method)
150
+ Refresh the access token
151
+
152
+ Args:
153
+ scope: the specific oauth2 scopes to request
154
+
155
+ Raises:
156
+ NoTokensError: no tokens (at all) are present
157
+ NoRefreshTokenError: no refresh token is present
158
+ InvalidRefreshTokenError: the token refresh failed
159
+ """
160
+ refresh_token = self.refresh_token
161
+ scope = scope or self._settings.scope
162
+
163
+ r = await self._ctx.httpx_client.post(
164
+ f"{self._settings.domain}oauth/token",
165
+ auth=None, # override client default
166
+ headers={"content-type": "application/x-www-form-urlencoded"},
167
+ data={
168
+ "grant_type": "refresh_token",
169
+ "client_id": self._settings.client_id,
170
+ "refresh_token": refresh_token,
171
+ "scopes": scope,
172
+ },
173
+ )
174
+ if r.status_code != 200:
175
+ logger.error(f"error during token refresh: {r.content}")
176
+ raise InvalidRefreshTokenError("refresh token exchange failed")
177
+
178
+ # add previous refresh token to new tokens if omitted from resp
179
+ # (i.e. we have a non-rotating refresh token)
180
+ resp: dict = r.json()
181
+ resp.setdefault("refresh_token", refresh_token)
182
+
183
+ self._validate_and_save_tokens(resp)
184
+
185
+ logger.debug(f"Refreshed tokens: {self._tokens}")
186
+ return self
187
+
188
+ async def async_refresh_if_necessary(
189
+ self,
190
+ scope: Optional[str] = None,
191
+ auto_refresh_threshold: int = 60,
192
+ ):
287
193
  """
288
- pass
194
+ Refresh the access token if it is expired or bootstrap the access token from a refresh token
195
+
196
+ Args:
197
+ scope: the specific oauth2 scopes to request
198
+ auto_refresh_threshold: access token TTL remaining (in seconds) before auto-refreshing
289
199
 
290
- @abstractmethod
291
- def save_tokens(self, creds: ValidTokens):
200
+ Raises:
201
+ NoTokensError: no tokens (at all) are present
202
+ NoRefreshTokenError: no refresh token is present
203
+ InvalidRefreshTokenError: the token refresh failed
292
204
  """
293
- Save the token (abstract method)
205
+ # Suppress to allow bootstrapping with a refresh token
206
+ with suppress(NoAccessTokenError):
207
+ if self.ttl >= dt.timedelta(seconds=auto_refresh_threshold):
208
+ return self
209
+
210
+ return await self.async_refresh(scope=scope)
294
211
 
295
- Parameters
296
- ----------
297
- creds : ValidTokens
212
+ async def async_revoke_refresh_token(self):
298
213
  """
299
- pass
214
+ Revoke the refresh token.
300
215
 
301
- def refresh(self, scope: Optional[str] = None, revoke: bool = False):
216
+ This invalidates the refresh token server-side so it may never again be used to
217
+ get new access tokens.
218
+
219
+ Raises:
220
+ NoTokensError: no tokens (at all) are present
221
+ NoRefreshTokenError: no refresh token is present
222
+ InvalidRefreshTokenError: the token revocation failed
302
223
  """
303
- Refresh the access token
224
+ refresh_token = self.refresh_token
304
225
 
305
- Parameters
306
- __________
307
- scope : str, optional
308
- The specific actions Auth0 applications can be allowed to do or information that they can request on a user’s behalf.
309
- revoke: bool, optional
310
- Whether to revoke the refresh token (default is False)
311
-
312
- Raises
313
- ______
314
- InvalidRefreshTokenError
315
- If the token refresh fails
316
- """
317
- scope = scope or self.token_scope
318
-
319
- if not self.has_refresh_token:
320
- raise NoRefreshTokenError("No refresh token was found. Please re-authenticate.")
321
-
322
- if revoke:
323
- r = requests.post(
324
- f"https://{self.auth0_domain}/oauth/revoke",
325
- headers={"content-type": "application/json"},
326
- data=json.dumps({
327
- "client_id": self.auth0_client_id,
328
- "token": self.refresh_token,
329
- }),
330
- )
331
- if r.status_code == 200:
332
- logger.debug(f"Refresh token revoked: {self.refresh_token}")
333
- return
334
- else:
335
- raise RuntimeError(r.json()["detail"])
336
-
337
-
338
- else:
339
- r = requests.post(
340
- f"https://{self.auth0_domain}/oauth/token",
341
- headers={"content-type": "application/x-www-form-urlencoded"},
342
- data={
343
- "grant_type": "refresh_token",
344
- "client_id": self.auth0_client_id,
345
- "refresh_token": self.refresh_token,
346
- "scopes": scope,
347
- },
348
- )
349
-
350
- # add previous refresh token to new access token
351
- if r.status_code == 200:
352
- refresh_token = self.refresh_token
353
- self.validate_and_save_tokens(r.json())
354
- if not self.has_refresh_token:
355
- self._tokens.refresh_token = refresh_token
356
- self.save_tokens(self._tokens)
357
- logger.debug(f"Refreshed tokens: {self._tokens}")
358
- return self
226
+ r = await self._ctx.httpx_client.post(
227
+ f"{self._settings.domain}oauth/revoke",
228
+ auth=None, # override client default
229
+ headers={"content-type": "application/json"},
230
+ json={
231
+ "client_id": self._settings.client_id,
232
+ "token": refresh_token,
233
+ },
234
+ )
235
+
236
+ if r.status_code != 200:
237
+ logger.error(f"error while revoking refresh token: {r.content}")
238
+ raise InvalidRefreshTokenError("refresh token revocation failed")
359
239
 
360
- raise InvalidRefreshTokenError
240
+ logger.debug(f"Refresh token revoked: {refresh_token}")
241
+ return self
361
242
 
362
- def validate_and_save_tokens(self, unvalidated_creds: Dict):
243
+ def _validate_and_save_tokens(self, unvalidated_tokens: dict):
363
244
  """
364
- Validate and save the token
245
+ Validate then save tokens to local storage
365
246
 
366
- Parameters
367
- __________
368
- unvalidated_creds: dict
369
- The unvalidated credentials to be validated
247
+ Args:
248
+ unvalidated_tokens: tokens that have yet to be validated
249
+
250
+ Returns:
251
+ this auth flow
252
+
253
+ Raises:
254
+ pydantic.ValidationError: the token's body could not be decoded
370
255
  """
371
- self.validate_tokens(unvalidated_creds)
256
+ self._validate_tokens(unvalidated_tokens)
257
+
372
258
  try:
373
- self.save_tokens(self._tokens)
259
+ self._ctx.settings.write_tokens(self._tokens)
374
260
  except Exception as e:
375
261
  logger.error("Error while persisting tokens", exc_info=e)
262
+ raise
376
263
 
377
264
  return self
378
265
 
379
- def validate_tokens(self, unvalidated_creds: Dict):
266
+ def _validate_tokens(self, unvalidated_tokens: dict):
380
267
  """
381
- Validate the token
268
+ Validate the tokens
269
+
270
+ Args:
271
+ unvalidated_tokens: tokens that have yet to be validated
382
272
 
383
- Paramaters
384
- ----------
385
- unvalidated_creds: dict
386
- The unvalidated credentials to be validated
273
+ Returns:
274
+ this auth flow
275
+
276
+ Raises:
277
+ pydantic.ValidationError: the token's body could not be decoded
387
278
  """
388
- creds = UnvalidatedTokens.from_dict(unvalidated_creds)
389
- idt_body: Optional[Dict] = None
390
279
 
391
- try:
392
- at_body = jwt.decode(
393
- creds.access_token,
394
- options={"verify_signature": False},
395
- )
396
-
397
- if creds.id_token:
398
- idt_body = jwt.decode(
399
- creds.id_token,
400
- options={"verify_signature": False},
401
- )
402
- except Exception as e:
403
- logger.error("Invalid tokens", exc_info=e)
404
- raise
280
+ tokens = Tokens.model_validate(unvalidated_tokens)
405
281
 
406
- self._access_token_body = at_body
407
- self._id_token_body = idt_body
408
- self._tokens = ValidTokens.from_unvalidated(
409
- creds=creds,
410
- body=at_body,
411
- )
412
- self.token_scope = self._tokens.scope
282
+ self._access_token_body = tokens.access_token_body
283
+ self._tokens = tokens
413
284
 
414
285
  return self
415
286
 
416
- def get_access_token_refresh_if_necessary(self, no_auto_refresh: bool = False, auto_refresh_threshold: int = 3600):
287
+ ##########
288
+ # The following methods make this class compatible with httpx.Auth.
289
+ ##########
290
+
291
+ async def async_auth_flow(self, request: httpx.Request):
292
+ """
293
+ Injects authorization into the request
294
+
295
+ (this method makes this class httpx.Auth compatible)
417
296
  """
418
- Retrieve the access token. Automatically refreshes the token when necessary.
297
+ super().async_auth_flow
298
+ if request.headers.get("authorization") is None:
299
+ await self.async_refresh_if_necessary()
300
+ access_token = self.access_token
301
+ request.headers["authorization"] = f"Bearer {access_token}"
419
302
 
420
- Parameters
421
- ___________
422
- no_auto_refresh: bool, optional
423
- Disable automatic token refresh. The default behavior is to automatically attempt to refresh the token if it can be refreshed and there is less than 1 hour before the token expires (default is False)
424
- auto_refresh_threshold: int, optional
425
- The amount of time remaining (in seconds) before token expiration after which a refresh is automatically attempted (default is 3600)
303
+ yield request
304
+
305
+ def sync_auth_flow(self, request: httpx.Request):
306
+ """
307
+ Injects authorization into the request
426
308
 
427
- Return
428
- ________
429
- access_token: str
309
+ (this method makes this class httpx.Auth compatible)
430
310
  """
311
+ # NOTE: we explicitly redefine this sync method because ctx.syncify()
312
+ # does not support generators
313
+ if request.headers.get("authorization") is None:
314
+ self.refresh_if_necessary()
315
+ access_token = self.access_token
316
+ request.headers["authorization"] = f"Bearer {access_token}"
431
317
 
432
- self.load_tokens()
433
- if (
434
- not no_auto_refresh
435
- and self.ttl < dt.timedelta(seconds=auto_refresh_threshold)
436
- ):
437
- try:
438
- self.refresh()
439
- except InvalidRefreshTokenError:
440
- raise InvalidRefreshTokenError("Unable to refresh because the refresh token is not valid. Use 'no-auto-refresh' option to get token anyway. To resolve, re-authenticate")
441
- return self.access_token
318
+ yield request