earthscope-sdk 0.2.0__py3-none-any.whl → 1.0.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.
- earthscope_sdk/__init__.py +5 -1
- earthscope_sdk/auth/auth_flow.py +240 -346
- earthscope_sdk/auth/client_credentials_flow.py +42 -162
- earthscope_sdk/auth/device_code_flow.py +169 -213
- earthscope_sdk/auth/error.py +46 -0
- earthscope_sdk/client/__init__.py +3 -0
- earthscope_sdk/client/_client.py +35 -0
- earthscope_sdk/client/user/_base.py +39 -0
- earthscope_sdk/client/user/_service.py +94 -0
- earthscope_sdk/client/user/models.py +53 -0
- earthscope_sdk/common/__init__.py +0 -0
- earthscope_sdk/common/_sync_runner.py +141 -0
- earthscope_sdk/common/client.py +99 -0
- earthscope_sdk/common/context.py +174 -0
- earthscope_sdk/common/service.py +59 -0
- earthscope_sdk/config/__init__.py +0 -0
- earthscope_sdk/config/_bootstrap.py +42 -0
- earthscope_sdk/config/_compat.py +148 -0
- earthscope_sdk/config/_util.py +48 -0
- earthscope_sdk/config/error.py +4 -0
- earthscope_sdk/config/models.py +310 -0
- earthscope_sdk/config/settings.py +295 -0
- earthscope_sdk/model/secret.py +29 -0
- {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/METADATA +147 -123
- earthscope_sdk-1.0.0.dist-info/RECORD +30 -0
- {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/WHEEL +1 -1
- earthscope_sdk/user/user.py +0 -32
- earthscope_sdk-0.2.0.dist-info/RECORD +0 -12
- /earthscope_sdk/{user → client/user}/__init__.py +0 -0
- {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info/licenses}/LICENSE +0 -0
- {earthscope_sdk-0.2.0.dist-info → earthscope_sdk-1.0.0.dist-info}/top_level.txt +0 -0
@@ -1,190 +1,70 @@
|
|
1
|
-
from dataclasses import dataclass
|
2
|
-
from pathlib import Path
|
3
|
-
import json
|
4
1
|
import logging
|
5
|
-
import
|
2
|
+
from json import JSONDecodeError
|
6
3
|
|
7
|
-
from earthscope_sdk.auth.auth_flow import
|
8
|
-
|
9
|
-
|
10
|
-
NoTokensError,
|
11
|
-
UnauthorizedError,
|
12
|
-
ValidTokens
|
13
|
-
)
|
4
|
+
from earthscope_sdk.auth.auth_flow import AuthFlow
|
5
|
+
from earthscope_sdk.auth.error import ClientCredentialsFlowError, UnauthorizedError
|
6
|
+
from earthscope_sdk.common.context import SdkContext
|
14
7
|
|
15
8
|
logger = logging.getLogger(__name__)
|
16
9
|
|
17
10
|
|
18
|
-
class ClientCredentialsFlowError(AuthFlowError):
|
19
|
-
pass
|
20
|
-
|
21
|
-
|
22
|
-
@dataclass
|
23
|
-
class ClientCredentials:
|
24
|
-
client_id: str
|
25
|
-
client_secret: str
|
26
|
-
|
27
|
-
|
28
|
-
@dataclass
|
29
|
-
class GetTokensErrorResponse:
|
30
|
-
error: str
|
31
|
-
error_description: str
|
32
|
-
|
33
|
-
|
34
11
|
class ClientCredentialsFlow(AuthFlow):
|
35
12
|
"""
|
36
|
-
|
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.
|
13
|
+
Implements the oauth2 Client Credentials "machine-to-machine" (M2M) flow.
|
53
14
|
"""
|
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
15
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
client_id=client_credentials.client_id,
|
64
|
-
scope="",
|
65
|
-
)
|
66
|
-
# Flow management vars
|
67
|
-
self._secret = client_credentials.client_secret
|
16
|
+
def __init__(self, ctx: SdkContext):
|
17
|
+
if not ctx.settings.oauth2.client_secret:
|
18
|
+
raise ValueError("Client secret required for client credentials flow")
|
68
19
|
|
69
|
-
|
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()
|
20
|
+
super().__init__(ctx=ctx)
|
78
21
|
|
79
|
-
|
80
|
-
|
22
|
+
# httpx.Auth objects can be used in either sync or async clients so we
|
23
|
+
# facilitate both from the same class
|
24
|
+
self.request_tokens = ctx.syncify(self.async_request_tokens)
|
81
25
|
|
82
|
-
|
26
|
+
async def async_refresh(self, *args, **kwargs):
|
27
|
+
# alias for self.request_tokens() so that base class can get new tokens automagically
|
28
|
+
return await self.async_request_tokens()
|
83
29
|
|
84
|
-
def
|
30
|
+
async def async_request_tokens(self):
|
85
31
|
"""
|
86
|
-
Request
|
32
|
+
Request access token from IdP
|
87
33
|
|
88
|
-
Raises
|
89
|
-
|
90
|
-
|
91
|
-
If the request returns as unauthorized
|
92
|
-
ClientCredentialsFlowError
|
93
|
-
If there is an error in the clientcredentials flow other than unauthorized
|
34
|
+
Raises:
|
35
|
+
Unauthorized Error: the request returns as unauthorized
|
36
|
+
ClientCredentialsFlowError: unhandled error in the client credentials flow
|
94
37
|
"""
|
95
|
-
r =
|
96
|
-
f"
|
38
|
+
r = await self._ctx.httpx_client.post(
|
39
|
+
f"{self._settings.domain}oauth/token",
|
40
|
+
auth=None, # override client default
|
97
41
|
headers={"content-type": "application/x-www-form-urlencoded"},
|
98
42
|
data={
|
99
43
|
"grant_type": "client_credentials",
|
100
|
-
"client_id": self.
|
101
|
-
"client_secret": self.
|
102
|
-
"audience": self.
|
44
|
+
"client_id": self._settings.client_id,
|
45
|
+
"client_secret": self._settings.client_secret.get_secret_value(),
|
46
|
+
"audience": self._settings.audience,
|
103
47
|
},
|
104
48
|
)
|
49
|
+
try:
|
50
|
+
resp: dict = r.json()
|
51
|
+
except JSONDecodeError:
|
52
|
+
raise ClientCredentialsFlowError(
|
53
|
+
f"Unable to unpack IdP response: {r.content}"
|
54
|
+
)
|
55
|
+
|
56
|
+
# Success!
|
105
57
|
if r.status_code == 200:
|
106
|
-
self.
|
58
|
+
self._validate_and_save_tokens(resp)
|
59
|
+
|
107
60
|
logger.debug(f"Got tokens: {self.tokens}")
|
108
61
|
return self
|
109
62
|
|
63
|
+
# Unauthorized
|
110
64
|
if r.status_code == 401:
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
raise UnauthorizedError
|
115
|
-
|
116
|
-
raise ClientCredentialsFlowError
|
65
|
+
raise UnauthorizedError(
|
66
|
+
f"m2m client '{self._settings.client_id}' is not authorized. IdP response: {resp}"
|
67
|
+
)
|
117
68
|
|
118
|
-
|
119
|
-
|
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)
|
157
|
-
|
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)
|
69
|
+
# Unhandled
|
70
|
+
raise ClientCredentialsFlowError("client credentials flow failed", r.content)
|
@@ -1,16 +1,20 @@
|
|
1
|
-
import json
|
2
1
|
import logging
|
3
|
-
import
|
4
|
-
|
5
|
-
from abc import abstractmethod
|
6
|
-
from dataclasses import dataclass
|
2
|
+
from asyncio import sleep
|
3
|
+
from contextlib import asynccontextmanager, contextmanager
|
7
4
|
from enum import Enum
|
8
|
-
from
|
9
|
-
from time import sleep
|
5
|
+
from json import JSONDecodeError
|
10
6
|
from typing import Optional
|
11
7
|
|
12
|
-
from
|
8
|
+
from pydantic import BaseModel, ValidationError
|
13
9
|
|
10
|
+
from earthscope_sdk.auth.auth_flow import AuthFlow
|
11
|
+
from earthscope_sdk.auth.error import (
|
12
|
+
DeviceCodePollingError,
|
13
|
+
DeviceCodePollingExpiredError,
|
14
|
+
DeviceCodeRequestDeviceCodeError,
|
15
|
+
UnauthorizedError,
|
16
|
+
)
|
17
|
+
from earthscope_sdk.common.context import SdkContext
|
14
18
|
|
15
19
|
logger = logging.getLogger(__name__)
|
16
20
|
|
@@ -22,273 +26,225 @@ class PollingErrorType(str, Enum):
|
|
22
26
|
ACCESS_DENIED = "access_denied"
|
23
27
|
|
24
28
|
|
25
|
-
class
|
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
|
-
@dataclass
|
42
|
-
class GetDeviceCodeResponse:
|
29
|
+
class GetDeviceCodeResponse(BaseModel):
|
43
30
|
device_code: str
|
44
31
|
user_code: str
|
45
32
|
verification_uri: str
|
46
33
|
verification_uri_complete: str
|
47
34
|
expires_in: int
|
48
|
-
interval:
|
35
|
+
interval: float
|
49
36
|
|
50
37
|
|
51
|
-
|
52
|
-
class PollingErrorResponse:
|
38
|
+
class PollingErrorResponse(BaseModel):
|
53
39
|
error: PollingErrorType
|
54
40
|
error_description: str
|
55
41
|
|
56
42
|
|
57
43
|
class DeviceCodeFlow(AuthFlow):
|
58
44
|
"""
|
59
|
-
|
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
|
45
|
+
Implements the oauth2 Device Code flow.
|
87
46
|
"""
|
47
|
+
|
88
48
|
@property
|
89
49
|
def polling(self):
|
90
|
-
"
|
50
|
+
"""
|
51
|
+
This instance is in the process of polling for tokens
|
52
|
+
"""
|
91
53
|
return self._is_polling
|
92
54
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
super().__init__(
|
100
|
-
domain=domain,
|
101
|
-
audience=audience,
|
102
|
-
client_id=client_id,
|
103
|
-
scope=scope,
|
104
|
-
)
|
55
|
+
def __init__(self, ctx: SdkContext):
|
56
|
+
if ctx.settings.oauth2.client_secret:
|
57
|
+
raise ValueError("Client secret should not be used with device code flow")
|
58
|
+
|
59
|
+
super().__init__(ctx=ctx)
|
60
|
+
|
105
61
|
# Flow management vars
|
106
62
|
self._is_polling = False
|
107
|
-
self._device_codes: Optional[GetDeviceCodeResponse] = None
|
108
63
|
|
109
|
-
|
110
|
-
|
111
|
-
|
64
|
+
# httpx.Auth objects can be used in either sync or async clients so we
|
65
|
+
# facilitate both from the same class
|
66
|
+
self._poll = ctx.syncify(self._async_poll)
|
67
|
+
self._request_device_code = ctx.syncify(self._async_request_device_code)
|
112
68
|
|
113
|
-
|
114
|
-
|
115
|
-
|
69
|
+
@asynccontextmanager
|
70
|
+
async def async_do_flow(self, scope: Optional[str] = None):
|
71
|
+
"""
|
72
|
+
Perform the oauth2 Device Code flow:
|
73
|
+
- requests device code
|
74
|
+
- yields the device code to the caller for them to prompt the user
|
75
|
+
- polls for access token
|
76
|
+
|
77
|
+
Args:
|
78
|
+
scope: the specific oauth2 scopes to request
|
79
|
+
|
80
|
+
Yields:
|
81
|
+
GetDeviceCodeResponse: the device code response that must be shown to the user
|
82
|
+
to facilitate a login.
|
83
|
+
|
84
|
+
Raises:
|
85
|
+
DeviceCodeRequestDeviceCodeError: failed to get a device code from the IdP
|
86
|
+
DeviceCodePollingExpiredError: the polling token expired
|
87
|
+
UnauthorizedError: access denied
|
88
|
+
DeviceCodePollingError: unhandled polling error
|
89
|
+
|
90
|
+
Examples:
|
91
|
+
>>> async with device_flow.async_do_flow() as codes:
|
92
|
+
... # prompt the user to login using the device code
|
93
|
+
... print(f"Open the following URL in a web browser to login: {codes.verification_uri_complete}")
|
116
94
|
"""
|
117
|
-
|
118
|
-
|
119
|
-
self.
|
120
|
-
|
95
|
+
# NOTE: if you update this method, also update the synchronous version
|
96
|
+
|
97
|
+
codes = await self._async_request_device_code(scope=scope)
|
98
|
+
|
99
|
+
# Yield codes to facilitate prompting the user
|
100
|
+
yield codes
|
101
|
+
|
102
|
+
await self._async_poll(codes=codes)
|
121
103
|
|
122
|
-
|
104
|
+
@contextmanager
|
105
|
+
def do_flow(self, scope: Optional[str] = None):
|
123
106
|
"""
|
124
|
-
|
107
|
+
Perform the oauth2 Device Code flow:
|
108
|
+
- requests device code
|
109
|
+
- yields the device code to the caller for them to prompt the user
|
110
|
+
- polls for access token
|
111
|
+
|
112
|
+
Args:
|
113
|
+
scope: the specific oauth2 scopes to request
|
114
|
+
|
115
|
+
Yields:
|
116
|
+
GetDeviceCodeResponse: the device code response that must be shown to the user
|
117
|
+
to facilitate a login.
|
118
|
+
|
119
|
+
Raises:
|
120
|
+
DeviceCodeRequestDeviceCodeError: failed to get a device code from the IdP
|
121
|
+
DeviceCodePollingExpiredError: the polling token expired
|
122
|
+
UnauthorizedError: access denied
|
123
|
+
DeviceCodePollingError: unhandled polling error
|
124
|
+
|
125
|
+
Examples:
|
126
|
+
>>> with device_flow.do_flow() as codes:
|
127
|
+
... # prompt the user to login using the device code
|
128
|
+
... print(f"Open the following URL in a web browser to login: {codes.verification_uri_complete}")
|
125
129
|
"""
|
126
|
-
|
127
|
-
|
130
|
+
# NOTE: we explicitly redefine this sync method because ctx.syncify()
|
131
|
+
# does not support generators
|
132
|
+
|
133
|
+
codes = self._request_device_code(scope=scope)
|
134
|
+
|
135
|
+
# Yield codes to facilitate prompting the user
|
136
|
+
yield codes
|
137
|
+
|
138
|
+
self._poll(codes=codes)
|
139
|
+
|
140
|
+
async def _async_poll(self, codes: GetDeviceCodeResponse):
|
141
|
+
"""
|
142
|
+
Polling IdP for tokens.
|
143
|
+
|
144
|
+
Args:
|
145
|
+
codes: device code response from IdP after starting the flow
|
128
146
|
|
147
|
+
Raises:
|
148
|
+
DeviceCodePollingExpiredError: the polling token expired
|
149
|
+
UnauthorizedError: access denied
|
150
|
+
DeviceCodePollingError: unhandled polling error
|
151
|
+
"""
|
129
152
|
if self._is_polling:
|
130
|
-
raise
|
153
|
+
raise DeviceCodePollingError("Polling is already in progress")
|
131
154
|
|
132
155
|
self._is_polling = True
|
133
156
|
try:
|
134
157
|
while True:
|
135
|
-
|
158
|
+
# IdP-provided poll interval
|
159
|
+
await sleep(codes.interval)
|
136
160
|
|
137
|
-
r =
|
138
|
-
f"
|
161
|
+
r = await self._ctx.httpx_client.post(
|
162
|
+
f"{self._settings.domain}oauth/token",
|
163
|
+
auth=None, # override client default
|
139
164
|
headers={"content-type": "application/x-www-form-urlencoded"},
|
140
165
|
data={
|
141
166
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
142
|
-
"device_code":
|
143
|
-
"client_id": self.
|
167
|
+
"device_code": codes.device_code,
|
168
|
+
"client_id": self._settings.client_id,
|
144
169
|
},
|
145
170
|
)
|
171
|
+
|
172
|
+
try:
|
173
|
+
resp: dict = r.json()
|
174
|
+
except JSONDecodeError:
|
175
|
+
raise DeviceCodePollingError(
|
176
|
+
f"Unable to unpack IdP response: {r.content}"
|
177
|
+
)
|
178
|
+
|
179
|
+
# Success!
|
146
180
|
if r.status_code == 200:
|
147
|
-
|
148
|
-
|
181
|
+
self._validate_and_save_tokens(resp)
|
182
|
+
|
183
|
+
logger.debug(f"Got tokens: {self.tokens}")
|
149
184
|
return self
|
150
185
|
|
151
|
-
|
186
|
+
# Keep polling
|
187
|
+
try:
|
188
|
+
poll_err = PollingErrorResponse.model_validate(resp)
|
189
|
+
except ValidationError as e:
|
190
|
+
raise DeviceCodePollingError(
|
191
|
+
f"Failed to unpack polling response: {r.text}"
|
192
|
+
) from e
|
152
193
|
if poll_err.error in [
|
153
194
|
PollingErrorType.AUTHORIZATION_PENDING,
|
154
195
|
PollingErrorType.SLOW_DOWN,
|
155
196
|
]:
|
156
197
|
continue
|
157
198
|
|
199
|
+
# Timeout
|
158
200
|
if poll_err.error == PollingErrorType.EXPIRED_TOKEN:
|
159
|
-
raise
|
201
|
+
raise DeviceCodePollingExpiredError
|
160
202
|
|
203
|
+
# Unauthorized
|
161
204
|
if poll_err.error == PollingErrorType.ACCESS_DENIED:
|
162
|
-
raise
|
205
|
+
raise UnauthorizedError
|
163
206
|
|
207
|
+
# Unhandled
|
164
208
|
if poll_err:
|
165
|
-
raise
|
209
|
+
raise DeviceCodePollingError(f"Unknown polling error: {poll_err}")
|
210
|
+
|
166
211
|
finally:
|
167
212
|
self._is_polling = False
|
168
213
|
|
169
|
-
|
170
|
-
def prompt_user(self):
|
171
|
-
pass
|
172
|
-
|
173
|
-
def request_device_code(self):
|
214
|
+
async def _async_request_device_code(self, scope: Optional[str] = None):
|
174
215
|
"""
|
175
|
-
Request
|
216
|
+
Request new device code from IdP
|
217
|
+
|
218
|
+
Args:
|
219
|
+
scope: the specific oauth2 scopes to request
|
176
220
|
|
177
|
-
Raises
|
178
|
-
|
179
|
-
RequestDeviceTokensError
|
180
|
-
failed to get a device code
|
221
|
+
Raises:
|
222
|
+
DeviceCodeRequestDeviceCodeError: failed to get a device code from the IdP
|
181
223
|
"""
|
182
|
-
|
183
|
-
|
224
|
+
scope = scope or self._settings.scope
|
225
|
+
|
226
|
+
r = await self._ctx.httpx_client.post(
|
227
|
+
f"{self._settings.domain}oauth/device/code",
|
228
|
+
auth=None, # override client default
|
184
229
|
headers={"content-type": "application/x-www-form-urlencoded"},
|
185
230
|
data={
|
186
|
-
"client_id": self.
|
187
|
-
"scope":
|
188
|
-
"audience": self.
|
231
|
+
"client_id": self._settings.client_id,
|
232
|
+
"scope": scope,
|
233
|
+
"audience": self._settings.audience,
|
189
234
|
},
|
190
235
|
)
|
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
|
-
|
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
236
|
|
247
|
-
|
248
|
-
|
249
|
-
|
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"
|
237
|
+
if r.status_code != 200:
|
238
|
+
raise DeviceCodeRequestDeviceCodeError(
|
239
|
+
f"Failed to get a device code: {r.content}"
|
288
240
|
)
|
289
241
|
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
242
|
+
try:
|
243
|
+
codes = GetDeviceCodeResponse.model_validate_json(r.content)
|
244
|
+
except ValidationError as e:
|
245
|
+
raise DeviceCodeRequestDeviceCodeError(
|
246
|
+
f"Failed to unpack device code response: {r.text}"
|
247
|
+
) from e
|
248
|
+
|
249
|
+
logger.debug(f"Got device code response: {codes}")
|
250
|
+
return codes
|