earthscope-sdk 0.2.0__py3-none-any.whl → 1.0.0b0__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.
@@ -1,30 +1,14 @@
1
- from dataclasses import dataclass
2
- from pathlib import Path
3
- import json
4
1
  import logging
5
- import requests
2
+ from dataclasses import dataclass
3
+ from json import JSONDecodeError
6
4
 
7
- from earthscope_sdk.auth.auth_flow import (
8
- AuthFlow,
9
- AuthFlowError,
10
- NoTokensError,
11
- UnauthorizedError,
12
- ValidTokens
13
- )
5
+ from earthscope_sdk.auth.auth_flow import AuthFlow
6
+ from earthscope_sdk.auth.error import ClientCredentialsFlowError, UnauthorizedError
7
+ from earthscope_sdk.common.context import SdkContext
14
8
 
15
9
  logger = logging.getLogger(__name__)
16
10
 
17
11
 
18
- class ClientCredentialsFlowError(AuthFlowError):
19
- pass
20
-
21
-
22
- @dataclass
23
- class ClientCredentials:
24
- client_id: str
25
- client_secret: str
26
-
27
-
28
12
  @dataclass
29
13
  class GetTokensErrorResponse:
30
14
  error: str
@@ -33,158 +17,64 @@ class GetTokensErrorResponse:
33
17
 
34
18
  class ClientCredentialsFlow(AuthFlow):
35
19
  """
36
- This is an abstract subclass of the AuthFlow abstract class. This class handles the client credential flow actions.
37
-
38
- Attributes
39
- __________
40
- audience : str
41
- Auth0 API Identifier (default is 'https://account.earthscope.org')
42
- domain : str
43
- Auth0 tenant domain URL or custom domain (default is 'login.earthscope.org')
44
- client_credentials: ClientCredentials
45
- The client credentials - client id and client secret
46
- _secret: str
47
- The client secret of the machine-to-machine application from the client_credentials
48
-
49
- Methods
50
- -------
51
- request_tokens
52
- Request the token from Auth0. Validates and saves the token locally.
20
+ Implements the oauth2 Client Credentials "machine-to-machine" (M2M) flow.
53
21
  """
54
- def __init__(
55
- self, domain: str, audience: str, client_credentials: ClientCredentials
56
- ) -> None:
57
- if not client_credentials or not client_credentials.client_secret:
58
- raise ValueError("Client secret missing")
59
-
60
- super().__init__(
61
- domain=domain,
62
- audience=audience,
63
- client_id=client_credentials.client_id,
64
- scope="",
65
- )
66
- # Flow management vars
67
- self._secret = client_credentials.client_secret
68
22
 
69
- def get_token_or_request_if_expired(self):
70
- """
71
- Loads the access token if it is currently saved locally.
72
- Requests the token if the token is expired or if no token is found locally
73
- """
74
- try:
75
- self.load_tokens()
76
- if self.ttl.total_seconds() <= 0:
77
- self.request_tokens()
23
+ def __init__(self, ctx: SdkContext):
24
+ if not ctx.settings.oauth2.client_secret:
25
+ raise ValueError("Client secret required for client credentials flow")
26
+
27
+ super().__init__(ctx=ctx)
78
28
 
79
- except NoTokensError:
80
- self.request_tokens()
29
+ # httpx.Auth objects can be used in either sync or async clients so we
30
+ # facilitate both from the same class
31
+ self.request_tokens = ctx.syncify(self.async_request_tokens)
81
32
 
82
- return self.access_token
33
+ async def async_refresh(self, *args, **kwargs):
34
+ # alias for self.request_tokens() so that base class can get new tokens automagically
35
+ return await self.async_request_tokens()
83
36
 
84
- def request_tokens(self):
37
+ async def async_request_tokens(self):
85
38
  """
86
- Request the token from Auth0. Validates and saves the token locally.
87
-
88
- Raises
89
- ------
90
- Unauthorized Error
91
- If the request returns as unauthorized
92
- ClientCredentialsFlowError
93
- If there is an error in the clientcredentials flow other than unauthorized
39
+ Request access token from IdP
40
+
41
+ Raises:
42
+ Unauthorized Error: the request returns as unauthorized
43
+ ClientCredentialsFlowError: unhandled error in the client credentials flow
94
44
  """
95
- r = requests.post(
96
- f"https://{self.auth0_domain}/oauth/token",
45
+ r = await self._ctx.httpx_client.post(
46
+ f"{self._settings.domain}oauth/token",
47
+ auth=None, # override client default
97
48
  headers={"content-type": "application/x-www-form-urlencoded"},
98
49
  data={
99
50
  "grant_type": "client_credentials",
100
- "client_id": self.auth0_client_id,
101
- "client_secret": self._secret,
102
- "audience": self.auth0_audience,
51
+ "client_id": self._settings.client_id,
52
+ "client_secret": self._settings.client_secret.get_secret_value(),
53
+ "audience": self._settings.audience,
103
54
  },
104
55
  )
56
+ try:
57
+ resp: dict = r.json()
58
+ except JSONDecodeError:
59
+ raise ClientCredentialsFlowError(
60
+ f"Unable to unpack IdP response: {r.content}"
61
+ )
62
+
63
+ # Success!
105
64
  if r.status_code == 200:
106
- self.validate_and_save_tokens(r.json())
65
+ self._validate_and_save_tokens(resp)
66
+
107
67
  logger.debug(f"Got tokens: {self.tokens}")
108
68
  return self
109
69
 
70
+ # Unauthorized
110
71
  if r.status_code == 401:
111
- err = GetTokensErrorResponse(**r.json())
72
+ err = GetTokensErrorResponse(**resp)
112
73
  if err.error == "access_denied":
113
74
  if err.error_description == "Unauthorized":
114
- raise UnauthorizedError
115
-
116
- raise ClientCredentialsFlowError
117
-
118
-
119
- class ClientCredentialsFlowSimple(ClientCredentialsFlow):
120
- """
121
- A simple concrete implementation of the ClientCredentialsFlow class. This will store and load tokens on your filesystem
122
-
123
- Attributes:
124
- -----------
125
- audience : str, optional
126
- Auth0 API Identifier (default is 'https://account.earthscope.org')
127
- client_credentials: ClientCredentials
128
- the client credentials
129
- domain : str, optional
130
- Auth0 tenant domain URL or custom domain (default is 'login.earthscope.org')
131
- path : str
132
- The path where your access token will be stored and read
133
-
134
- Methods
135
- -------
136
- load_tokens
137
- Loads and validates your access token from the location specified by the given path.
138
- save_tokens(creds)
139
- Saves your access token to the location specified by the given path.
140
- """
141
- def __init__(
142
- self,
143
- client_id: str,
144
- client_secret: str,
145
- path: str,
146
- domain: str = "login.earthscope.org",
147
- audience: str = "https://account.earthscope.org",
148
- ) -> None:
149
- super().__init__(
150
- domain=domain,
151
- audience=audience,
152
- client_credentials=ClientCredentials(
153
- client_id=client_id, client_secret=client_secret
154
- ),
155
- )
156
- self.path = Path(path)
75
+ raise UnauthorizedError(
76
+ f"m2m client '{self._settings.client_id}' is not authorized"
77
+ )
157
78
 
158
- def load_tokens(self):
159
- """
160
- Loads and validates the access token from the location specified by the given path.
161
-
162
- Raises
163
- ------
164
- NoTokensError
165
- If no token is found
166
- Returns
167
- _______
168
- valid token : str
169
- """
170
- try:
171
- with self.path.open() as f:
172
- json_state = json.load(f)
173
- except FileNotFoundError:
174
- raise NoTokensError
175
-
176
- self.validate_tokens(json_state)
177
- return self.tokens
178
-
179
- def save_tokens(self, creds: ValidTokens):
180
- """
181
- Writes the token to the location specified by the given path in json format.
182
-
183
- Parameters
184
- ----------
185
- creds: ValidTokens
186
- token credentials
187
- """
188
- json_str_state = json.dumps(vars(creds))
189
- with self.path.open("w") as f:
190
- f.write(json_str_state)
79
+ # Unhandled
80
+ raise ClientCredentialsFlowError("client credentials flow failed", r.content)
@@ -1,16 +1,19 @@
1
- import json
2
1
  import logging
3
- import requests
4
-
5
- from abc import abstractmethod
2
+ from contextlib import asynccontextmanager, contextmanager
6
3
  from dataclasses import dataclass
7
4
  from enum import Enum
8
- from pathlib import Path
5
+ from json import JSONDecodeError
9
6
  from time import sleep
10
7
  from typing import Optional
11
8
 
12
- from earthscope_sdk.auth.auth_flow import AuthFlow, NoTokensError, ValidTokens
13
-
9
+ from earthscope_sdk.auth.auth_flow import AuthFlow
10
+ from earthscope_sdk.auth.error import (
11
+ DeviceCodePollingError,
12
+ DeviceCodePollingExpiredError,
13
+ DeviceCodeRequestDeviceCodeError,
14
+ UnauthorizedError,
15
+ )
16
+ from earthscope_sdk.common.context import SdkContext
14
17
 
15
18
  logger = logging.getLogger(__name__)
16
19
 
@@ -22,22 +25,6 @@ class PollingErrorType(str, Enum):
22
25
  ACCESS_DENIED = "access_denied"
23
26
 
24
27
 
25
- class RequestDeviceTokensError(RuntimeError):
26
- pass
27
-
28
-
29
- class PollingError(ValueError):
30
- pass
31
-
32
-
33
- class PollingExpiredError(PollingError):
34
- pass
35
-
36
-
37
- class PollingAccessDeniedError(PollingError):
38
- pass
39
-
40
-
41
28
  @dataclass
42
29
  class GetDeviceCodeResponse:
43
30
  device_code: str
@@ -56,239 +43,199 @@ class PollingErrorResponse:
56
43
 
57
44
  class DeviceCodeFlow(AuthFlow):
58
45
  """
59
- This is an abstract subclass of the AuthFlow abstract class. This class handles the device code flow actions.
60
-
61
- Atttributes
62
- -----------
63
- domain : str
64
- Auth0 tenant domain URL or custom domain
65
- audience : str
66
- Auth0 API Identifier
67
- client_id : str
68
- Identification value of Auth0 Application
69
- scope : str
70
- The specific actions Auth0 applications can be allowed to do or information that they can request on a user’s behalf
71
- _is_polling: bool, optional
72
- True if currently polling (default is False)
73
- _device_codes, optional
74
- The device code (default is None)
75
-
76
- Methods
77
- -------
78
- do_flow
79
- Requests the device code, prompts the user to complete the device code flow, polls while waiting for completion
80
- and returns the token
81
- poll
82
- Polls for completion of the device code flow
83
- prompt_user
84
- abstract method for prompting the user to complette the device code flow
85
- request_device_code
86
- requests the device code from Auth0
46
+ Implements the oauth2 Device Code flow.
87
47
  """
48
+
88
49
  @property
89
50
  def polling(self):
90
- "If is in proccess of polling"
51
+ """
52
+ This instance is in the process of polling for tokens
53
+ """
91
54
  return self._is_polling
92
55
 
93
- @property
94
- def started(self):
95
- "Returns a boolean on if the device code flow has started"
96
- return self._device_codes is not None
97
-
98
- def __init__(self, domain: str, audience: str, client_id: str, scope: str) -> None:
99
- super().__init__(
100
- domain=domain,
101
- audience=audience,
102
- client_id=client_id,
103
- scope=scope,
104
- )
56
+ def __init__(self, ctx: SdkContext):
57
+ if ctx.settings.oauth2.client_secret:
58
+ raise ValueError("Client secret should not be used with device code flow")
59
+
60
+ super().__init__(ctx=ctx)
61
+
105
62
  # Flow management vars
106
63
  self._is_polling = False
107
- self._device_codes: Optional[GetDeviceCodeResponse] = None
108
64
 
109
- def do_flow(self):
110
- """
111
- Runs request_device_code, prompt_user, and poll methods. This is the device code flow "login" process.
65
+ # httpx.Auth objects can be used in either sync or async clients so we
66
+ # facilitate both from the same class
67
+ self._poll = ctx.syncify(self._async_poll)
68
+ self._request_device_code = ctx.syncify(self._async_request_device_code)
112
69
 
113
- Returns
114
- -------
115
- token
70
+ @asynccontextmanager
71
+ async def async_do_flow(self, scope: Optional[str] = None):
116
72
  """
117
- self.request_device_code()
118
- self.prompt_user()
119
- self.poll()
120
- return self.tokens
73
+ Perform the oauth2 Device Code flow:
74
+ - requests device code
75
+ - yields the device code to the caller for them to prompt the user
76
+ - polls for access token
77
+
78
+ Args:
79
+ scope: the specific oauth2 scopes to request
80
+
81
+ Yields:
82
+ GetDeviceCodeResponse: the device code response that must be shown to the user
83
+ to facilitate a login.
84
+
85
+ Raises:
86
+ DeviceCodeRequestDeviceCodeError: failed to get a device code from the IdP
87
+ DeviceCodePollingExpiredError: the polling token expired
88
+ UnauthorizedError: access denied
89
+ DeviceCodePollingError: unhandled polling error
90
+
91
+ Examples:
92
+ >>> async with device_flow.async_do_flow() as codes:
93
+ ... # prompt the user to login using the device code
94
+ ... print(f"Open the following URL in a web browser to login: {codes.verification_uri_complete}")
95
+ """
96
+ # NOTE: if you update this method, also update the synchronous version
97
+
98
+ codes = await self._async_request_device_code(scope=scope)
99
+
100
+ # Yield codes to facilitate prompting the user
101
+ yield codes
121
102
 
122
- def poll(self):
103
+ await self._async_poll(codes=codes)
104
+
105
+ @contextmanager
106
+ def do_flow(self, scope: Optional[str] = None):
107
+ """
108
+ Perform the oauth2 Device Code flow:
109
+ - requests device code
110
+ - yields the device code to the caller for them to prompt the user
111
+ - polls for access token
112
+
113
+ Args:
114
+ scope: the specific oauth2 scopes to request
115
+
116
+ Yields:
117
+ GetDeviceCodeResponse: the device code response that must be shown to the user
118
+ to facilitate a login.
119
+
120
+ Raises:
121
+ DeviceCodeRequestDeviceCodeError: failed to get a device code from the IdP
122
+ DeviceCodePollingExpiredError: the polling token expired
123
+ UnauthorizedError: access denied
124
+ DeviceCodePollingError: unhandled polling error
125
+
126
+ Examples:
127
+ >>> with device_flow.do_flow() as codes:
128
+ ... # prompt the user to login using the device code
129
+ ... print(f"Open the following URL in a web browser to login: {codes.verification_uri_complete}")
123
130
  """
124
- Polling auth0 for token. Sets self._is_polling.
131
+ # NOTE: we explicitly redefine this sync method because ctx.syncify()
132
+ # does not support generators
133
+
134
+ codes = self._request_device_code(scope=scope)
135
+
136
+ # Yield codes to facilitate prompting the user
137
+ yield codes
138
+
139
+ self._poll(codes=codes)
140
+
141
+ async def _async_poll(self, codes: GetDeviceCodeResponse):
125
142
  """
126
- if not self._device_codes:
127
- raise PollingError("Cannot poll without initial device code response")
143
+ Polling IdP for tokens.
128
144
 
145
+ Args:
146
+ codes: device code response from IdP after starting the flow
147
+
148
+ Raises:
149
+ DeviceCodePollingExpiredError: the polling token expired
150
+ UnauthorizedError: access denied
151
+ DeviceCodePollingError: unhandled polling error
152
+ """
129
153
  if self._is_polling:
130
- raise PollingError("Attempted to double poll")
154
+ raise DeviceCodePollingError("Polling is already in progress")
131
155
 
132
156
  self._is_polling = True
133
157
  try:
134
158
  while True:
135
- sleep(self._device_codes.interval)
159
+ # IdP-provided poll interval
160
+ sleep(codes.interval)
136
161
 
137
- r = requests.post(
138
- f"https://{self.auth0_domain}/oauth/token",
162
+ r = await self._ctx.httpx_client.post(
163
+ f"{self._settings.domain}oauth/token",
164
+ auth=None, # override client default
139
165
  headers={"content-type": "application/x-www-form-urlencoded"},
140
166
  data={
141
167
  "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
142
- "device_code": self._device_codes.device_code,
143
- "client_id": self.auth0_client_id,
168
+ "device_code": codes.device_code,
169
+ "client_id": self._settings.client_id,
144
170
  },
145
171
  )
172
+
173
+ try:
174
+ resp: dict = r.json()
175
+ except JSONDecodeError:
176
+ raise DeviceCodePollingError(
177
+ f"Unable to unpack IdP response: {r.content}"
178
+ )
179
+
180
+ # Success!
146
181
  if r.status_code == 200:
147
- tokens = self.validate_and_save_tokens(r.json())
148
- logger.debug(f"Got tokens: {tokens}")
182
+ self._validate_and_save_tokens(resp)
183
+
184
+ logger.debug(f"Got tokens: {self.tokens}")
149
185
  return self
150
186
 
151
- poll_err = PollingErrorResponse(**r.json())
187
+ # Keep polling
188
+ poll_err = PollingErrorResponse(**resp)
152
189
  if poll_err.error in [
153
190
  PollingErrorType.AUTHORIZATION_PENDING,
154
191
  PollingErrorType.SLOW_DOWN,
155
192
  ]:
156
193
  continue
157
194
 
195
+ # Timeout
158
196
  if poll_err.error == PollingErrorType.EXPIRED_TOKEN:
159
- raise PollingExpiredError
197
+ raise DeviceCodePollingExpiredError
160
198
 
199
+ # Unauthorized
161
200
  if poll_err.error == PollingErrorType.ACCESS_DENIED:
162
- raise PollingAccessDeniedError
201
+ raise UnauthorizedError
163
202
 
203
+ # Unhandled
164
204
  if poll_err:
165
- raise PollingError(f"Unknown polling error: {poll_err}")
205
+ raise DeviceCodePollingError(f"Unknown polling error: {poll_err}")
206
+
166
207
  finally:
167
208
  self._is_polling = False
168
209
 
169
- @abstractmethod
170
- def prompt_user(self):
171
- pass
172
-
173
- def request_device_code(self):
210
+ async def _async_request_device_code(self, scope: Optional[str] = None):
174
211
  """
175
- Request the device code from auth0 and sets self._device_codes
212
+ Request new device code from IdP
176
213
 
177
- Raises
178
- ------
179
- RequestDeviceTokensError
180
- failed to get a device code
214
+ Args:
215
+ scope: the specific oauth2 scopes to request
216
+
217
+ Raises:
218
+ DeviceCodeRequestDeviceCodeError: failed to get a device code from the IdP
181
219
  """
182
- r = requests.post(
183
- f"https://{self.auth0_domain}/oauth/device/code",
220
+ scope = scope or self._settings.scope
221
+
222
+ r = await self._ctx.httpx_client.post(
223
+ f"{self._settings.domain}oauth/device/code",
224
+ auth=None, # override client default
184
225
  headers={"content-type": "application/x-www-form-urlencoded"},
185
226
  data={
186
- "client_id": self.auth0_client_id,
187
- "scope": self.token_scope,
188
- "audience": self.auth0_audience,
227
+ "client_id": self._settings.client_id,
228
+ "scope": scope,
229
+ "audience": self._settings.audience,
189
230
  },
190
231
  )
191
- if r.status_code == 200:
192
- self._device_codes = GetDeviceCodeResponse(**r.json())
193
- logger.debug(f"Got device code response: {self._device_codes}")
194
- return self
195
-
196
- raise RequestDeviceTokensError(f"Failed to get a device code: {r.text}")
197
-
198
232
 
199
- class DeviceCodeFlowSimple(DeviceCodeFlow):
200
- """
201
- This is a concrete implementation of the DeviceCodeFlow abstract class. This will store tokens on your filesystem
202
- and print the user prompt to the standard out.
203
-
204
- Default Auth0 values are for the Production tenant for UNAVCO Data File Server Access Application and User Management API.
205
-
206
- Attributes:
207
- -----------
208
- path : str
209
- The path where your access token will be stored and read. If this is a directory, the file name will be assigned as sso_tokens.json. You maye provide your own file name if desired.
210
- domain : str, optional
211
- Auth0 tenant domain URL or custom domain (default is 'login.earthscope.org')
212
- audience : str, optional
213
- Auth0 API Identifier (default is 'https://account.earthscope.org')
214
- client_id : str, optional
215
- Identification value of Auth0 Application (default is 'b9DtAFBd6QvMg761vI3YhYquNZbJX5G0')
216
- scope : str, optional
217
- The specific actions Auth0 applications can be allowed to do or information that they can request on a user’s behalf. (default is "openid profile email offline_access")
218
-
219
- Methods
220
- -------
221
- load_tokens
222
- Loads and validates your access token from the location specified by the given path.
223
- save_tokens(creds)
224
- Saves your access token to the location specified by the given path.
225
- prompt_user
226
- Prints the link for the sso authorization flow.
227
- """
228
- def __init__(
229
- self,
230
- path: str,
231
- domain: str = "login.earthscope.org",
232
- audience: str = "https://account.earthscope.org",
233
- client_id: str = "b9DtAFBd6QvMg761vI3YhYquNZbJX5G0",
234
- scope: str = "openid profile email offline_access"
235
-
236
- ) -> None:
237
- super().__init__(
238
- domain=domain,
239
- audience=audience,
240
- client_id=client_id,
241
- scope=scope,
242
- )
243
- self.path = Path(path)
244
- if self.path.is_dir():
245
- self.path = self.path / "sso_tokens.json"
246
-
247
- def load_tokens(self):
248
- """
249
- Loads and validates your access token from the location specified by the given path.
250
-
251
- Raises
252
- ------
253
- NoTokensError
254
- If no token is found
255
- Returns
256
- _______
257
- valid token : str
258
- """
259
- try:
260
- with self.path.open() as f:
261
- json_state = json.load(f)
262
- except FileNotFoundError:
263
- raise NoTokensError
264
-
265
- self.validate_tokens(json_state)
266
- return self.tokens
267
-
268
- def save_tokens(self, creds: ValidTokens):
269
- """
270
- writes your access token to the location specified by the given path in json format.
271
-
272
- Parameters
273
- ----------
274
- creds: ValidTokens
275
- token credentials
276
- """
277
- json_str_state = json.dumps(vars(creds))
278
- with self.path.open("w") as f:
279
- f.write(json_str_state)
280
-
281
- def prompt_user(self):
282
- """
283
- Prints the url to complete the sso authorization flow to to stdout
284
- """
285
- if not self._device_codes:
286
- raise RuntimeError(
287
- "You must start the device flow before prompting the user"
233
+ if r.status_code != 200:
234
+ raise DeviceCodeRequestDeviceCodeError(
235
+ f"Failed to get a device code: {r.content}"
288
236
  )
289
237
 
290
- print(
291
- f"""To complete the SSO authorization, please visit the following URL in a browser of your choice:
292
- {self._device_codes.verification_uri_complete}
293
- """
294
- )
238
+ codes = GetDeviceCodeResponse(**r.json())
239
+
240
+ logger.debug(f"Got device code response: {codes}")
241
+ return codes