apizit-cli 0.2.0b1__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.
- apizit_cli/__init__.py +3 -0
- apizit_cli/authentication.py +396 -0
- apizit_cli/commands.py +337 -0
- apizit_cli/configuration.py +246 -0
- apizit_cli/context.py +155 -0
- apizit_cli/credential_store.py +170 -0
- apizit_cli/errors.py +19 -0
- apizit_cli/http.py +162 -0
- apizit_cli/main.py +164 -0
- apizit_cli/operations.py +745 -0
- apizit_cli/sources.py +259 -0
- apizit_cli-0.2.0b1.dist-info/METADATA +44 -0
- apizit_cli-0.2.0b1.dist-info/RECORD +16 -0
- apizit_cli-0.2.0b1.dist-info/WHEEL +5 -0
- apizit_cli-0.2.0b1.dist-info/entry_points.txt +2 -0
- apizit_cli-0.2.0b1.dist-info/top_level.txt +1 -0
apizit_cli/__init__.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Native OAuth with PKCE, a loopback callback and an OS credential vault."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import webbrowser
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
15
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from apizit_cli.credential_store import CredentialStore, session_lock
|
|
20
|
+
from apizit_cli.errors import CliError, EXIT_AUTH, EXIT_NETWORK, EXIT_USAGE
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def system_vault():
|
|
24
|
+
try:
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
from keyring.backends.Windows import WinVaultKeyring
|
|
27
|
+
|
|
28
|
+
vault = WinVaultKeyring()
|
|
29
|
+
elif sys.platform == "darwin":
|
|
30
|
+
from keyring.backends.macOS import Keyring
|
|
31
|
+
|
|
32
|
+
vault = Keyring()
|
|
33
|
+
else:
|
|
34
|
+
from keyring.backends.SecretService import Keyring
|
|
35
|
+
|
|
36
|
+
vault = Keyring()
|
|
37
|
+
if vault.priority <= 0:
|
|
38
|
+
raise RuntimeError()
|
|
39
|
+
return vault
|
|
40
|
+
except Exception:
|
|
41
|
+
raise CliError(
|
|
42
|
+
"The system credential vault is unavailable. Unlock it, or use an injected CI token.",
|
|
43
|
+
code="CREDENTIAL_VAULT_UNAVAILABLE",
|
|
44
|
+
exit_code=EXIT_AUTH,
|
|
45
|
+
) from None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NativeSession:
|
|
49
|
+
def __init__(self, origin, *, http=None, vault=None, injected_token=None):
|
|
50
|
+
self.origin = origin
|
|
51
|
+
self.http = http or requests.Session()
|
|
52
|
+
self.http.trust_env = False
|
|
53
|
+
self._vault = vault
|
|
54
|
+
self.injected_token = (
|
|
55
|
+
injected_token if injected_token is not None else os.getenv("APIZIT_TOKEN")
|
|
56
|
+
)
|
|
57
|
+
self.access_token = None
|
|
58
|
+
self.expires_at = 0
|
|
59
|
+
self._configuration = None
|
|
60
|
+
self.vault_service = "APIZIT CLI:" + origin
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def vault(self):
|
|
64
|
+
if self._vault is None:
|
|
65
|
+
self._vault = system_vault()
|
|
66
|
+
return self._vault
|
|
67
|
+
|
|
68
|
+
def configuration(self):
|
|
69
|
+
if self._configuration is not None:
|
|
70
|
+
return self._configuration
|
|
71
|
+
try:
|
|
72
|
+
response = self.http.get(
|
|
73
|
+
self.origin + "/auth/cli-configuration", timeout=20, allow_redirects=False
|
|
74
|
+
)
|
|
75
|
+
if response.status_code != 200:
|
|
76
|
+
raise ValueError()
|
|
77
|
+
value = response.json()
|
|
78
|
+
if not isinstance(value, dict) or not value.get("client_id"):
|
|
79
|
+
raise ValueError()
|
|
80
|
+
origins = set()
|
|
81
|
+
for field in ("authorization_endpoint", "token_endpoint", "revocation_endpoint"):
|
|
82
|
+
parsed = urlsplit(value[field])
|
|
83
|
+
if (
|
|
84
|
+
parsed.scheme != "https"
|
|
85
|
+
or not parsed.hostname
|
|
86
|
+
or parsed.username
|
|
87
|
+
or parsed.password
|
|
88
|
+
or parsed.query
|
|
89
|
+
or parsed.fragment
|
|
90
|
+
):
|
|
91
|
+
raise ValueError()
|
|
92
|
+
origins.add((parsed.scheme, parsed.netloc))
|
|
93
|
+
if len(origins) != 1:
|
|
94
|
+
raise ValueError()
|
|
95
|
+
callback = urlsplit(value["redirect_uri"])
|
|
96
|
+
if (
|
|
97
|
+
callback.scheme != "http"
|
|
98
|
+
or callback.hostname != "127.0.0.1"
|
|
99
|
+
or callback.username
|
|
100
|
+
or callback.password
|
|
101
|
+
or not callback.port
|
|
102
|
+
or not callback.path.startswith("/")
|
|
103
|
+
or callback.query
|
|
104
|
+
or callback.fragment
|
|
105
|
+
):
|
|
106
|
+
raise ValueError()
|
|
107
|
+
self._configuration = value
|
|
108
|
+
return value
|
|
109
|
+
except (requests.RequestException, ValueError, KeyError, TypeError):
|
|
110
|
+
raise CliError(
|
|
111
|
+
"The APIZIT login configuration is unavailable.",
|
|
112
|
+
code="LOGIN_CONFIGURATION_UNAVAILABLE",
|
|
113
|
+
exit_code=EXIT_NETWORK,
|
|
114
|
+
) from None
|
|
115
|
+
|
|
116
|
+
def _stored(self):
|
|
117
|
+
try:
|
|
118
|
+
value = CredentialStore(self.vault, self.vault_service).read()
|
|
119
|
+
saved = json.loads(value) if value else None
|
|
120
|
+
if saved is not None and (
|
|
121
|
+
not isinstance(saved, dict) or not isinstance(saved.get("refresh_token"), str)
|
|
122
|
+
):
|
|
123
|
+
raise ValueError()
|
|
124
|
+
return saved
|
|
125
|
+
except CliError:
|
|
126
|
+
raise
|
|
127
|
+
except Exception:
|
|
128
|
+
raise CliError(
|
|
129
|
+
"The APIZIT session could not be read from the credential vault.",
|
|
130
|
+
code="CREDENTIAL_VAULT_UNAVAILABLE",
|
|
131
|
+
exit_code=EXIT_AUTH,
|
|
132
|
+
) from None
|
|
133
|
+
|
|
134
|
+
def _save(self, tokens, configuration, *, previous=None):
|
|
135
|
+
refresh = tokens.get("refresh_token") or (previous or {}).get("refresh_token")
|
|
136
|
+
access = tokens.get("access_token")
|
|
137
|
+
expiry = tokens.get("expires_in")
|
|
138
|
+
if (
|
|
139
|
+
not isinstance(refresh, str)
|
|
140
|
+
or not refresh
|
|
141
|
+
or not isinstance(access, str)
|
|
142
|
+
or not access
|
|
143
|
+
or type(expiry) is not int
|
|
144
|
+
or expiry <= 0
|
|
145
|
+
):
|
|
146
|
+
raise CliError(
|
|
147
|
+
"The login provider returned an incomplete session.",
|
|
148
|
+
code="LOGIN_RESPONSE_INVALID",
|
|
149
|
+
exit_code=EXIT_AUTH,
|
|
150
|
+
)
|
|
151
|
+
try:
|
|
152
|
+
CredentialStore(self.vault, self.vault_service).write(
|
|
153
|
+
json.dumps(
|
|
154
|
+
{
|
|
155
|
+
"refresh_token": refresh,
|
|
156
|
+
"client_id": configuration["client_id"],
|
|
157
|
+
}
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
except CliError:
|
|
161
|
+
raise
|
|
162
|
+
except Exception:
|
|
163
|
+
raise CliError(
|
|
164
|
+
"The APIZIT session could not be saved securely.",
|
|
165
|
+
code="CREDENTIAL_VAULT_UNAVAILABLE",
|
|
166
|
+
exit_code=EXIT_AUTH,
|
|
167
|
+
) from None
|
|
168
|
+
self.access_token = access
|
|
169
|
+
self.expires_at = time.monotonic() + expiry
|
|
170
|
+
|
|
171
|
+
def _token_request(self, configuration, data):
|
|
172
|
+
try:
|
|
173
|
+
response = self.http.post(
|
|
174
|
+
configuration["token_endpoint"], data=data, timeout=30, allow_redirects=False
|
|
175
|
+
)
|
|
176
|
+
if response.status_code != 200:
|
|
177
|
+
raise CliError(
|
|
178
|
+
"The session was rejected. Run apizit login.",
|
|
179
|
+
code="LOGIN_REQUIRED",
|
|
180
|
+
exit_code=EXIT_AUTH,
|
|
181
|
+
)
|
|
182
|
+
result = response.json()
|
|
183
|
+
if not isinstance(result, dict):
|
|
184
|
+
raise ValueError()
|
|
185
|
+
return result
|
|
186
|
+
except (requests.RequestException, ValueError):
|
|
187
|
+
raise CliError(
|
|
188
|
+
"Could not reach the login provider.",
|
|
189
|
+
code="LOGIN_PROVIDER_UNAVAILABLE",
|
|
190
|
+
exit_code=EXIT_NETWORK,
|
|
191
|
+
) from None
|
|
192
|
+
|
|
193
|
+
def token(self, *, force_refresh=False):
|
|
194
|
+
if self.injected_token:
|
|
195
|
+
if (
|
|
196
|
+
any(char.isspace() for char in self.injected_token)
|
|
197
|
+
or len(self.injected_token) > 8192
|
|
198
|
+
):
|
|
199
|
+
raise CliError(
|
|
200
|
+
"Injected APIZIT token is invalid.", code="INVALID_TOKEN", exit_code=EXIT_AUTH
|
|
201
|
+
)
|
|
202
|
+
return self.injected_token
|
|
203
|
+
if self.access_token and not force_refresh and self.expires_at > time.monotonic() + 60:
|
|
204
|
+
return self.access_token
|
|
205
|
+
with session_lock(self.vault_service):
|
|
206
|
+
return self._renew()
|
|
207
|
+
|
|
208
|
+
def _renew(self):
|
|
209
|
+
saved = self._stored()
|
|
210
|
+
if not saved:
|
|
211
|
+
raise CliError(
|
|
212
|
+
"Sign in with apizit login or inject APIZIT_TOKEN.",
|
|
213
|
+
code="LOGIN_REQUIRED",
|
|
214
|
+
exit_code=EXIT_AUTH,
|
|
215
|
+
)
|
|
216
|
+
configuration = self.configuration()
|
|
217
|
+
if saved.get("client_id") != configuration["client_id"]:
|
|
218
|
+
raise CliError(
|
|
219
|
+
"The login configuration changed. Run apizit login.",
|
|
220
|
+
code="LOGIN_REQUIRED",
|
|
221
|
+
exit_code=EXIT_AUTH,
|
|
222
|
+
)
|
|
223
|
+
tokens = self._token_request(
|
|
224
|
+
configuration,
|
|
225
|
+
{
|
|
226
|
+
"grant_type": "refresh_token",
|
|
227
|
+
"client_id": configuration["client_id"],
|
|
228
|
+
"refresh_token": saved["refresh_token"],
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
self._save(tokens, configuration, previous=saved)
|
|
232
|
+
return self.access_token
|
|
233
|
+
|
|
234
|
+
def login(self, *, open_browser=True, non_interactive=False, timeout=180):
|
|
235
|
+
if non_interactive:
|
|
236
|
+
raise CliError(
|
|
237
|
+
"Interactive login is unavailable in non-interactive mode. Inject a CI token.",
|
|
238
|
+
code="INTERACTION_REQUIRED",
|
|
239
|
+
exit_code=EXIT_USAGE,
|
|
240
|
+
)
|
|
241
|
+
if self.injected_token:
|
|
242
|
+
raise CliError(
|
|
243
|
+
"Unset the injected token before starting an interactive login.",
|
|
244
|
+
code="INJECTED_TOKEN_ACTIVE",
|
|
245
|
+
exit_code=EXIT_USAGE,
|
|
246
|
+
)
|
|
247
|
+
configuration = self.configuration()
|
|
248
|
+
self.vault # Fail before OAuth if secure persistence is unavailable.
|
|
249
|
+
state = secrets.token_urlsafe(32)
|
|
250
|
+
verifier = secrets.token_urlsafe(64)
|
|
251
|
+
challenge = (
|
|
252
|
+
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
|
253
|
+
.rstrip(b"=")
|
|
254
|
+
.decode()
|
|
255
|
+
)
|
|
256
|
+
callback = urlsplit(configuration["redirect_uri"])
|
|
257
|
+
outcome = {}
|
|
258
|
+
deadline = time.monotonic() + timeout
|
|
259
|
+
|
|
260
|
+
class Callback(BaseHTTPRequestHandler):
|
|
261
|
+
def setup(self):
|
|
262
|
+
self.request.settimeout(max(0.1, min(3.0, deadline - time.monotonic())))
|
|
263
|
+
super().setup()
|
|
264
|
+
|
|
265
|
+
def do_GET(self):
|
|
266
|
+
parsed = urlsplit(self.path)
|
|
267
|
+
try:
|
|
268
|
+
query = parse_qs(parsed.query, max_num_fields=8, strict_parsing=True)
|
|
269
|
+
except ValueError:
|
|
270
|
+
query = {}
|
|
271
|
+
valid = (
|
|
272
|
+
parsed.path == callback.path
|
|
273
|
+
and not parsed.scheme
|
|
274
|
+
and not parsed.netloc
|
|
275
|
+
and self.headers.get("Host") == callback.netloc
|
|
276
|
+
and len(query.get("state", [])) == 1
|
|
277
|
+
and hmac.compare_digest(query["state"][0].encode("utf-8"), state.encode())
|
|
278
|
+
and not ("code" in query and "error" in query)
|
|
279
|
+
and (
|
|
280
|
+
(len(query.get("code", [])) == 1 and 0 < len(query["code"][0]) <= 4096)
|
|
281
|
+
or (len(query.get("error", [])) == 1 and 0 < len(query["error"][0]) <= 256)
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
if valid and len(query.get("code", [])) == 1 and 0 < len(query["code"][0]) <= 4096:
|
|
285
|
+
outcome["code"] = query["code"][0]
|
|
286
|
+
elif valid and "error" in query:
|
|
287
|
+
outcome["error"] = True
|
|
288
|
+
self.send_response(200 if valid else 400)
|
|
289
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
290
|
+
self.send_header("Cache-Control", "no-store")
|
|
291
|
+
self.send_header("Content-Security-Policy", "default-src 'none'")
|
|
292
|
+
self.end_headers()
|
|
293
|
+
self.wfile.write(
|
|
294
|
+
b"Return to the APIZIT terminal." if valid else b"Invalid login callback."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def log_message(self, *_args):
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
server = HTTPServer(("127.0.0.1", callback.port), Callback)
|
|
302
|
+
except OSError:
|
|
303
|
+
raise CliError(
|
|
304
|
+
"The local login callback port is unavailable. Close the other login and retry.",
|
|
305
|
+
code="LOGIN_CALLBACK_UNAVAILABLE",
|
|
306
|
+
exit_code=EXIT_AUTH,
|
|
307
|
+
) from None
|
|
308
|
+
url = (
|
|
309
|
+
configuration["authorization_endpoint"]
|
|
310
|
+
+ "?"
|
|
311
|
+
+ urlencode(
|
|
312
|
+
{
|
|
313
|
+
"response_type": "code",
|
|
314
|
+
"client_id": configuration["client_id"],
|
|
315
|
+
"redirect_uri": configuration["redirect_uri"],
|
|
316
|
+
"state": state,
|
|
317
|
+
"scope": " ".join(configuration["scopes"]),
|
|
318
|
+
"code_challenge": challenge,
|
|
319
|
+
"code_challenge_method": "S256",
|
|
320
|
+
}
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
print("Complete APIZIT sign-in in your browser:\n" + url, file=sys.stderr)
|
|
324
|
+
try:
|
|
325
|
+
if open_browser:
|
|
326
|
+
webbrowser.open(url)
|
|
327
|
+
server.timeout = 1
|
|
328
|
+
while not outcome and time.monotonic() < deadline:
|
|
329
|
+
server.handle_request()
|
|
330
|
+
finally:
|
|
331
|
+
server.server_close()
|
|
332
|
+
if "code" not in outcome:
|
|
333
|
+
raise CliError(
|
|
334
|
+
"Login was not completed. Run apizit login to retry.",
|
|
335
|
+
code="LOGIN_NOT_COMPLETED",
|
|
336
|
+
exit_code=EXIT_AUTH,
|
|
337
|
+
)
|
|
338
|
+
tokens = self._token_request(
|
|
339
|
+
configuration,
|
|
340
|
+
{
|
|
341
|
+
"grant_type": "authorization_code",
|
|
342
|
+
"code": outcome["code"],
|
|
343
|
+
"client_id": configuration["client_id"],
|
|
344
|
+
"redirect_uri": configuration["redirect_uri"],
|
|
345
|
+
"code_verifier": verifier,
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
with session_lock(self.vault_service):
|
|
349
|
+
self._save(tokens, configuration)
|
|
350
|
+
return {"signed_in": True}
|
|
351
|
+
|
|
352
|
+
def logout(self):
|
|
353
|
+
if self.injected_token:
|
|
354
|
+
raise CliError(
|
|
355
|
+
"An injected CI token is not a local session. Revoke it with token revoke, or unset APIZIT_TOKEN.",
|
|
356
|
+
code="INJECTED_TOKEN_ACTIVE",
|
|
357
|
+
exit_code=EXIT_USAGE,
|
|
358
|
+
)
|
|
359
|
+
with session_lock(self.vault_service):
|
|
360
|
+
return self._logout()
|
|
361
|
+
|
|
362
|
+
def _logout(self):
|
|
363
|
+
incomplete = False
|
|
364
|
+
try:
|
|
365
|
+
saved = self._stored()
|
|
366
|
+
except CliError:
|
|
367
|
+
saved, incomplete = None, True
|
|
368
|
+
revoked = not bool(saved) and not incomplete
|
|
369
|
+
try:
|
|
370
|
+
if saved:
|
|
371
|
+
configuration = self.configuration()
|
|
372
|
+
response = self.http.post(
|
|
373
|
+
configuration["revocation_endpoint"],
|
|
374
|
+
data={
|
|
375
|
+
"token": saved["refresh_token"],
|
|
376
|
+
"client_id": configuration["client_id"],
|
|
377
|
+
},
|
|
378
|
+
timeout=20,
|
|
379
|
+
allow_redirects=False,
|
|
380
|
+
)
|
|
381
|
+
revoked = response.status_code == 200
|
|
382
|
+
except (requests.RequestException, CliError):
|
|
383
|
+
revoked = False
|
|
384
|
+
finally:
|
|
385
|
+
if saved or incomplete:
|
|
386
|
+
try:
|
|
387
|
+
CredentialStore(self.vault, self.vault_service).delete()
|
|
388
|
+
except Exception:
|
|
389
|
+
raise CliError(
|
|
390
|
+
"The local session could not be removed from the vault.",
|
|
391
|
+
code="CREDENTIAL_VAULT_UNAVAILABLE",
|
|
392
|
+
exit_code=EXIT_AUTH,
|
|
393
|
+
) from None
|
|
394
|
+
self.access_token = None
|
|
395
|
+
self.expires_at = 0
|
|
396
|
+
return {"signed_out": True, "renewal_revoked": revoked}
|