earthscope-sdk 0.2.0__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,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