netconverter-cli 0.4.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.
- netconverter_cli/__init__.py +3 -0
- netconverter_cli/activity.py +83 -0
- netconverter_cli/api.py +257 -0
- netconverter_cli/backend.py +107 -0
- netconverter_cli/branding.py +79 -0
- netconverter_cli/catalog.py +162 -0
- netconverter_cli/clerk.py +602 -0
- netconverter_cli/cli.py +521 -0
- netconverter_cli/commands.py +63 -0
- netconverter_cli/connect.py +465 -0
- netconverter_cli/controller.py +331 -0
- netconverter_cli/credentials.py +94 -0
- netconverter_cli/guided.py +365 -0
- netconverter_cli/mcp_server.py +640 -0
- netconverter_cli/palette.py +16 -0
- netconverter_cli/presentation.py +254 -0
- netconverter_cli/privacy.py +105 -0
- netconverter_cli/providers.py +246 -0
- netconverter_cli/remote_mcp.py +175 -0
- netconverter_cli/setup.py +179 -0
- netconverter_cli/tui.py +607 -0
- netconverter_cli/update.py +344 -0
- netconverter_cli/workspace.py +148 -0
- netconverter_cli-0.4.0.dist-info/METADATA +167 -0
- netconverter_cli-0.4.0.dist-info/RECORD +28 -0
- netconverter_cli-0.4.0.dist-info/WHEEL +4 -0
- netconverter_cli-0.4.0.dist-info/entry_points.txt +2 -0
- netconverter_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""Clerk OAuth 2.1 sign-in for the terminal (public client, PKCE S256).
|
|
2
|
+
|
|
3
|
+
Endpoints are read from the issuer's discovery document at runtime; only the
|
|
4
|
+
issuer, the public client id, the scopes and the loopback callback are fixed
|
|
5
|
+
here. Tokens live only in the OS credential store (``credentials.py``) or in
|
|
6
|
+
memory; they are never written to the workspace, printed, or given to a model.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import html
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import secrets
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import webbrowser
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
23
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from .credentials import delete_secret, keyring_available, load_secret, save_secret
|
|
28
|
+
|
|
29
|
+
ISSUER = "https://clerk.netconverter.ai"
|
|
30
|
+
CLIENT_ID = "RjSv1z2Gms6iS96s"
|
|
31
|
+
SCOPES = "openid profile email offline_access user:org:read"
|
|
32
|
+
# RFC 8707 resource indicator. The server's audience check names the MCP
|
|
33
|
+
# resource; Claude Code sends the same value, and NetConverter's REST door
|
|
34
|
+
# applies the identical check, so one token works for both.
|
|
35
|
+
RESOURCE = "https://api.netconverter.ai/mcp/v1"
|
|
36
|
+
# Dedicated loopback callback for this client (Claude Code uses 33418).
|
|
37
|
+
CALLBACK_HOST = "127.0.0.1"
|
|
38
|
+
CALLBACK_PORT = 33419
|
|
39
|
+
CALLBACK_PATH = "/callback"
|
|
40
|
+
REDIRECT_URI = f"http://{CALLBACK_HOST}:{CALLBACK_PORT}{CALLBACK_PATH}"
|
|
41
|
+
CALLBACK_TIMEOUT_S = 300
|
|
42
|
+
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
43
|
+
# Refresh this many seconds before the access token's stated expiry.
|
|
44
|
+
EXPIRY_SKEW_S = 60
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ClerkError(RuntimeError):
|
|
48
|
+
"""A sign-in failure with a message safe to show (never a token)."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _safe_text(value, limit: int = 200) -> str:
|
|
52
|
+
text = re.sub(r"[^\x20-\x7e]", "", str(value or ""))
|
|
53
|
+
return text[:limit]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _http(transport=None) -> httpx.Client:
|
|
57
|
+
return httpx.Client(
|
|
58
|
+
timeout=30, follow_redirects=False, trust_env=False, transport=transport
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --------------------------------------------------------------------------
|
|
63
|
+
# Discovery
|
|
64
|
+
# --------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Discovery:
|
|
69
|
+
issuer: str
|
|
70
|
+
authorization_endpoint: str
|
|
71
|
+
token_endpoint: str
|
|
72
|
+
device_authorization_endpoint: str | None = None
|
|
73
|
+
revocation_endpoint: str | None = None
|
|
74
|
+
userinfo_endpoint: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _endpoint(doc: dict, key: str, issuer_host: str, required: bool) -> str | None:
|
|
78
|
+
value = doc.get(key)
|
|
79
|
+
if not value:
|
|
80
|
+
if required:
|
|
81
|
+
raise ClerkError(f"Sign-in server discovery is missing {key}")
|
|
82
|
+
return None
|
|
83
|
+
parts = urlsplit(str(value))
|
|
84
|
+
if parts.scheme != "https" or parts.hostname != issuer_host:
|
|
85
|
+
raise ClerkError(f"Sign-in server advertised an unexpected {key}")
|
|
86
|
+
return str(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def discover(http: httpx.Client, issuer: str = ISSUER) -> Discovery:
|
|
90
|
+
issuer = issuer.rstrip("/")
|
|
91
|
+
host = urlsplit(issuer).hostname or ""
|
|
92
|
+
try:
|
|
93
|
+
response = http.get(issuer + "/.well-known/oauth-authorization-server")
|
|
94
|
+
response.raise_for_status()
|
|
95
|
+
doc = response.json()
|
|
96
|
+
except (httpx.HTTPError, ValueError):
|
|
97
|
+
raise ClerkError("Could not read the sign-in server discovery document") from None
|
|
98
|
+
if not isinstance(doc, dict) or str(doc.get("issuer", "")).rstrip("/") != issuer:
|
|
99
|
+
raise ClerkError("Sign-in server discovery issuer does not match")
|
|
100
|
+
methods = doc.get("code_challenge_methods_supported")
|
|
101
|
+
if methods is not None and "S256" not in methods:
|
|
102
|
+
raise ClerkError("Sign-in server does not support PKCE S256")
|
|
103
|
+
userinfo = None
|
|
104
|
+
try:
|
|
105
|
+
oidc = http.get(issuer + "/.well-known/openid-configuration")
|
|
106
|
+
if oidc.status_code == 200:
|
|
107
|
+
meta = oidc.json()
|
|
108
|
+
if str(meta.get("issuer", "")).rstrip("/") == issuer:
|
|
109
|
+
userinfo = _endpoint(meta, "userinfo_endpoint", host, False)
|
|
110
|
+
except (httpx.HTTPError, ValueError, ClerkError):
|
|
111
|
+
userinfo = None
|
|
112
|
+
return Discovery(
|
|
113
|
+
issuer=issuer,
|
|
114
|
+
authorization_endpoint=_endpoint(doc, "authorization_endpoint", host, True),
|
|
115
|
+
token_endpoint=_endpoint(doc, "token_endpoint", host, True),
|
|
116
|
+
device_authorization_endpoint=_endpoint(
|
|
117
|
+
doc, "device_authorization_endpoint", host, False
|
|
118
|
+
)
|
|
119
|
+
if DEVICE_GRANT in (doc.get("grant_types_supported") or [DEVICE_GRANT])
|
|
120
|
+
else None,
|
|
121
|
+
revocation_endpoint=_endpoint(doc, "revocation_endpoint", host, False),
|
|
122
|
+
userinfo_endpoint=userinfo
|
|
123
|
+
or _endpoint(doc, "userinfo_endpoint", host, False),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------
|
|
128
|
+
# Tokens
|
|
129
|
+
# --------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class TokenSet:
|
|
134
|
+
access_token: str
|
|
135
|
+
refresh_token: str = ""
|
|
136
|
+
expires_at: float = 0.0
|
|
137
|
+
scope: str = ""
|
|
138
|
+
token_type: str = "Bearer"
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_response(cls, body, now: float, previous_refresh: str = "") -> "TokenSet":
|
|
142
|
+
if not isinstance(body, dict) or not isinstance(body.get("access_token"), str):
|
|
143
|
+
raise ClerkError("Sign-in server returned no access token")
|
|
144
|
+
if str(body.get("token_type", "Bearer")).lower() != "bearer":
|
|
145
|
+
raise ClerkError("Sign-in server returned an unsupported token type")
|
|
146
|
+
try:
|
|
147
|
+
lifetime = int(body.get("expires_in", 3600))
|
|
148
|
+
except (TypeError, ValueError):
|
|
149
|
+
lifetime = 3600
|
|
150
|
+
return cls(
|
|
151
|
+
access_token=body["access_token"],
|
|
152
|
+
# Refresh tokens may rotate; keep the previous one if none is returned.
|
|
153
|
+
refresh_token=body.get("refresh_token") or previous_refresh,
|
|
154
|
+
expires_at=now + max(lifetime, 0),
|
|
155
|
+
scope=str(body.get("scope", "")),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def expired(self, now: float) -> bool:
|
|
159
|
+
return now >= self.expires_at - EXPIRY_SKEW_S
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _token_request(http: httpx.Client, disc: Discovery, form: dict) -> dict:
|
|
163
|
+
try:
|
|
164
|
+
response = http.post(
|
|
165
|
+
disc.token_endpoint, data=form, headers={"Accept": "application/json"}
|
|
166
|
+
)
|
|
167
|
+
except httpx.HTTPError:
|
|
168
|
+
raise ClerkError("Could not reach the sign-in server") from None
|
|
169
|
+
try:
|
|
170
|
+
body = response.json()
|
|
171
|
+
except ValueError:
|
|
172
|
+
body = {}
|
|
173
|
+
if response.is_error:
|
|
174
|
+
error = _safe_text(body.get("error") if isinstance(body, dict) else "", 60)
|
|
175
|
+
raise OAuthError(error or f"http_{response.status_code}", body)
|
|
176
|
+
return body
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class OAuthError(ClerkError):
|
|
180
|
+
def __init__(self, error: str, body=None):
|
|
181
|
+
description = ""
|
|
182
|
+
if isinstance(body, dict):
|
|
183
|
+
description = _safe_text(body.get("error_description"))
|
|
184
|
+
super().__init__(
|
|
185
|
+
f"Sign-in refused: {error}" + (f" ({description})" if description else "")
|
|
186
|
+
)
|
|
187
|
+
self.error = error
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def exchange_code(
|
|
191
|
+
http: httpx.Client, disc: Discovery, code: str, verifier: str,
|
|
192
|
+
redirect_uri: str = REDIRECT_URI, client_id: str = CLIENT_ID,
|
|
193
|
+
) -> TokenSet:
|
|
194
|
+
body = _token_request(
|
|
195
|
+
http,
|
|
196
|
+
disc,
|
|
197
|
+
{
|
|
198
|
+
"grant_type": "authorization_code",
|
|
199
|
+
"code": code,
|
|
200
|
+
"redirect_uri": redirect_uri,
|
|
201
|
+
"client_id": client_id,
|
|
202
|
+
"code_verifier": verifier,
|
|
203
|
+
"resource": RESOURCE,
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
return TokenSet.from_response(body, time.time())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def refresh_tokens(
|
|
210
|
+
http: httpx.Client, disc: Discovery, refresh_token: str, client_id: str = CLIENT_ID
|
|
211
|
+
) -> TokenSet:
|
|
212
|
+
body = _token_request(
|
|
213
|
+
http,
|
|
214
|
+
disc,
|
|
215
|
+
{"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": client_id,
|
|
216
|
+
"resource": RESOURCE},
|
|
217
|
+
)
|
|
218
|
+
return TokenSet.from_response(body, time.time(), previous_refresh=refresh_token)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --------------------------------------------------------------------------
|
|
222
|
+
# Browser flow (authorization code + PKCE S256, loopback redirect)
|
|
223
|
+
# --------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def pkce_pair() -> tuple[str, str]:
|
|
227
|
+
verifier = secrets.token_urlsafe(64)[:96] # RFC 7636: 43-128 characters
|
|
228
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
229
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
230
|
+
return verifier, challenge
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def authorization_url(
|
|
234
|
+
disc: Discovery, state: str, challenge: str,
|
|
235
|
+
redirect_uri: str = REDIRECT_URI, client_id: str = CLIENT_ID, scopes: str = SCOPES,
|
|
236
|
+
) -> str:
|
|
237
|
+
query = urlencode(
|
|
238
|
+
{
|
|
239
|
+
"response_type": "code",
|
|
240
|
+
"client_id": client_id,
|
|
241
|
+
"redirect_uri": redirect_uri,
|
|
242
|
+
"scope": scopes,
|
|
243
|
+
"state": state,
|
|
244
|
+
"code_challenge": challenge,
|
|
245
|
+
"code_challenge_method": "S256",
|
|
246
|
+
"resource": RESOURCE,
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
separator = "&" if urlsplit(disc.authorization_endpoint).query else "?"
|
|
250
|
+
return disc.authorization_endpoint + separator + query
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_callback(query: str, expected_state: str, issuer: str) -> str:
|
|
254
|
+
"""Validate a redirect's query string and return the authorization code."""
|
|
255
|
+
params = {k: v[0] for k, v in parse_qs(query, keep_blank_values=True).items()}
|
|
256
|
+
state = params.get("state", "")
|
|
257
|
+
if not state or not hmac.compare_digest(state.encode(), expected_state.encode()):
|
|
258
|
+
raise ClerkError(
|
|
259
|
+
"Sign-in response did not match this request (state mismatch); start again"
|
|
260
|
+
)
|
|
261
|
+
# RFC 9207: when present, the issuer must be the one we started with.
|
|
262
|
+
if "iss" in params and params["iss"].rstrip("/") != issuer.rstrip("/"):
|
|
263
|
+
raise ClerkError("Sign-in response came from an unexpected issuer")
|
|
264
|
+
if "error" in params:
|
|
265
|
+
error = _safe_text(params["error"], 60)
|
|
266
|
+
description = _safe_text(params.get("error_description"))
|
|
267
|
+
hint = {
|
|
268
|
+
"invalid_scope": "the OAuth application does not allow the requested scopes",
|
|
269
|
+
"access_denied": "sign-in was cancelled or denied",
|
|
270
|
+
"invalid_request": "the OAuth application rejected the request "
|
|
271
|
+
f"(is {REDIRECT_URI} a registered redirect URI?)",
|
|
272
|
+
"unauthorized_client": "this OAuth client may not use this sign-in method",
|
|
273
|
+
}.get(error, "")
|
|
274
|
+
raise ClerkError(
|
|
275
|
+
f"Sign-in failed: {error}"
|
|
276
|
+
+ (f" — {hint}" if hint else "")
|
|
277
|
+
+ (f" ({description})" if description else "")
|
|
278
|
+
)
|
|
279
|
+
code = params.get("code", "")
|
|
280
|
+
if not code:
|
|
281
|
+
raise ClerkError("Sign-in response contained no authorization code")
|
|
282
|
+
return code
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
_DONE_PAGE = (
|
|
286
|
+
"<!doctype html><meta charset=utf-8><title>NetConverter sign-in</title>"
|
|
287
|
+
"<body style='font-family:system-ui;background:#0b0d10;color:#e6e8eb;padding:3em'>"
|
|
288
|
+
"<h1>{title}</h1><p>{message}</p><p>You can close this window and return to the terminal.</p>"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
293
|
+
def do_GET(self): # noqa: N802 - http.server API
|
|
294
|
+
parts = urlsplit(self.path)
|
|
295
|
+
if parts.path != CALLBACK_PATH:
|
|
296
|
+
self.send_response(404)
|
|
297
|
+
self.end_headers()
|
|
298
|
+
return
|
|
299
|
+
try:
|
|
300
|
+
code = parse_callback(parts.query, self.server.expected_state, self.server.issuer)
|
|
301
|
+
self.server.result = ("ok", code)
|
|
302
|
+
title, message = "Signed in", "NetConverter received your sign-in."
|
|
303
|
+
except ClerkError as exc:
|
|
304
|
+
self.server.result = ("error", str(exc))
|
|
305
|
+
title, message = "Sign-in failed", str(exc)
|
|
306
|
+
page = _DONE_PAGE.format(title=html.escape(title), message=html.escape(message))
|
|
307
|
+
body = page.encode()
|
|
308
|
+
self.send_response(200)
|
|
309
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
310
|
+
self.send_header("Cache-Control", "no-store")
|
|
311
|
+
self.send_header("Content-Length", str(len(body)))
|
|
312
|
+
self.end_headers()
|
|
313
|
+
self.wfile.write(body)
|
|
314
|
+
|
|
315
|
+
def log_message(self, *args): # keep codes and states out of the terminal
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class CallbackServer:
|
|
320
|
+
"""One-shot loopback listener bound only to 127.0.0.1."""
|
|
321
|
+
|
|
322
|
+
def __init__(self, expected_state: str, issuer: str, port: int = CALLBACK_PORT):
|
|
323
|
+
try:
|
|
324
|
+
self.httpd = HTTPServer((CALLBACK_HOST, port), _CallbackHandler)
|
|
325
|
+
except OSError:
|
|
326
|
+
raise ClerkError(
|
|
327
|
+
f"Port {port} on {CALLBACK_HOST} is busy; close the other sign-in "
|
|
328
|
+
"or use `netconverter login --clerk --device`"
|
|
329
|
+
) from None
|
|
330
|
+
self.httpd.expected_state = expected_state
|
|
331
|
+
self.httpd.issuer = issuer
|
|
332
|
+
self.httpd.result = None
|
|
333
|
+
self.port = self.httpd.server_address[1]
|
|
334
|
+
|
|
335
|
+
def wait(self, timeout: float = CALLBACK_TIMEOUT_S, clock=time.monotonic) -> str:
|
|
336
|
+
deadline = clock() + timeout
|
|
337
|
+
try:
|
|
338
|
+
while self.httpd.result is None:
|
|
339
|
+
remaining = deadline - clock()
|
|
340
|
+
if remaining <= 0:
|
|
341
|
+
raise ClerkError(
|
|
342
|
+
"Timed out waiting for the browser sign-in; run the command again"
|
|
343
|
+
)
|
|
344
|
+
self.httpd.timeout = min(remaining, 1.0)
|
|
345
|
+
self.httpd.handle_request()
|
|
346
|
+
finally:
|
|
347
|
+
self.httpd.server_close()
|
|
348
|
+
kind, value = self.httpd.result
|
|
349
|
+
if kind != "ok":
|
|
350
|
+
raise ClerkError(value)
|
|
351
|
+
return value
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def browser_login(
|
|
355
|
+
http: httpx.Client, disc: Discovery, *, open_browser=webbrowser.open, notify=print,
|
|
356
|
+
port: int = CALLBACK_PORT, timeout: float = CALLBACK_TIMEOUT_S,
|
|
357
|
+
) -> TokenSet:
|
|
358
|
+
verifier, challenge = pkce_pair()
|
|
359
|
+
state = secrets.token_urlsafe(32)
|
|
360
|
+
server = CallbackServer(state, disc.issuer, port)
|
|
361
|
+
redirect_uri = f"http://{CALLBACK_HOST}:{server.port}{CALLBACK_PATH}"
|
|
362
|
+
url = authorization_url(disc, state, challenge, redirect_uri)
|
|
363
|
+
notify("Opening your browser to sign in to NetConverter.")
|
|
364
|
+
notify(f"If it does not open, visit:\n{url}")
|
|
365
|
+
try:
|
|
366
|
+
open_browser(url)
|
|
367
|
+
except Exception:
|
|
368
|
+
pass
|
|
369
|
+
code = server.wait(timeout)
|
|
370
|
+
return exchange_code(http, disc, code, verifier, redirect_uri)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# --------------------------------------------------------------------------
|
|
374
|
+
# Device flow (RFC 8628) for headless machines
|
|
375
|
+
# --------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def device_login(
|
|
379
|
+
http: httpx.Client, disc: Discovery, *, notify=print,
|
|
380
|
+
sleep=time.sleep, clock=time.monotonic, client_id: str = CLIENT_ID, scopes: str = SCOPES,
|
|
381
|
+
) -> TokenSet:
|
|
382
|
+
if not disc.device_authorization_endpoint:
|
|
383
|
+
raise ClerkError("This sign-in server does not offer device sign-in; use the browser flow")
|
|
384
|
+
try:
|
|
385
|
+
response = http.post(
|
|
386
|
+
disc.device_authorization_endpoint,
|
|
387
|
+
data={"client_id": client_id, "scope": scopes, "resource": RESOURCE},
|
|
388
|
+
headers={"Accept": "application/json"},
|
|
389
|
+
)
|
|
390
|
+
body = response.json()
|
|
391
|
+
except (httpx.HTTPError, ValueError):
|
|
392
|
+
raise ClerkError("Could not start device sign-in") from None
|
|
393
|
+
if response.is_error or not isinstance(body, dict) or "device_code" not in body:
|
|
394
|
+
raise OAuthError(_safe_text(body.get("error") if isinstance(body, dict) else "", 60)
|
|
395
|
+
or "device_authorization_failed", body)
|
|
396
|
+
verification = body.get("verification_uri_complete") or body.get("verification_uri")
|
|
397
|
+
if not verification or urlsplit(str(verification)).scheme != "https":
|
|
398
|
+
raise ClerkError("Device sign-in returned no usable verification address")
|
|
399
|
+
notify(f"To sign in, visit {_safe_text(verification, 300)}")
|
|
400
|
+
notify(f"and confirm the code: {_safe_text(body.get('user_code'), 40)}")
|
|
401
|
+
interval = max(int(body.get("interval", 5) or 5), 1)
|
|
402
|
+
deadline = clock() + int(body.get("expires_in", 600) or 600)
|
|
403
|
+
while clock() < deadline:
|
|
404
|
+
sleep(interval)
|
|
405
|
+
try:
|
|
406
|
+
token = _token_request(
|
|
407
|
+
http,
|
|
408
|
+
disc,
|
|
409
|
+
{"grant_type": DEVICE_GRANT, "device_code": body["device_code"], "client_id": client_id},
|
|
410
|
+
)
|
|
411
|
+
except OAuthError as exc:
|
|
412
|
+
if exc.error == "authorization_pending":
|
|
413
|
+
continue
|
|
414
|
+
if exc.error == "slow_down":
|
|
415
|
+
interval += 5
|
|
416
|
+
continue
|
|
417
|
+
if exc.error == "access_denied":
|
|
418
|
+
raise ClerkError("Device sign-in was denied") from None
|
|
419
|
+
if exc.error == "expired_token":
|
|
420
|
+
break
|
|
421
|
+
raise
|
|
422
|
+
return TokenSet.from_response(token, time.time())
|
|
423
|
+
raise ClerkError("Device sign-in code expired; run the command again")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# --------------------------------------------------------------------------
|
|
427
|
+
# Keyring storage and the refreshing session credential
|
|
428
|
+
# --------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class TokenStore:
|
|
432
|
+
"""Clerk tokens in the OS credential store; separate entries keep each small."""
|
|
433
|
+
|
|
434
|
+
def __init__(self, issuer: str = ISSUER, client_id: str = CLIENT_ID):
|
|
435
|
+
digest = hashlib.sha256(f"{issuer.rstrip('/')}|{client_id}".encode()).hexdigest()
|
|
436
|
+
self.account = "clerk:" + digest[:24]
|
|
437
|
+
|
|
438
|
+
def save(self, tokens: TokenSet) -> bool:
|
|
439
|
+
meta = json.dumps(
|
|
440
|
+
{"expires_at": tokens.expires_at, "scope": tokens.scope, "token_type": "Bearer"}
|
|
441
|
+
)
|
|
442
|
+
ok = save_secret(self.account + ":refresh", tokens.refresh_token or "")
|
|
443
|
+
ok = ok and save_secret(self.account + ":access", tokens.access_token)
|
|
444
|
+
ok = ok and save_secret(self.account + ":meta", meta)
|
|
445
|
+
if not ok:
|
|
446
|
+
self.clear()
|
|
447
|
+
return ok
|
|
448
|
+
|
|
449
|
+
def load(self) -> TokenSet | None:
|
|
450
|
+
access = load_secret(self.account + ":access")
|
|
451
|
+
if not access:
|
|
452
|
+
return None
|
|
453
|
+
try:
|
|
454
|
+
meta = json.loads(load_secret(self.account + ":meta") or "{}")
|
|
455
|
+
except ValueError:
|
|
456
|
+
meta = {}
|
|
457
|
+
return TokenSet(
|
|
458
|
+
access_token=access,
|
|
459
|
+
refresh_token=load_secret(self.account + ":refresh"),
|
|
460
|
+
expires_at=float(meta.get("expires_at", 0) or 0),
|
|
461
|
+
scope=str(meta.get("scope", "")),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def clear(self) -> bool:
|
|
465
|
+
results = [delete_secret(self.account + suffix) for suffix in (":access", ":refresh", ":meta")]
|
|
466
|
+
return all(results)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class ClerkSession:
|
|
470
|
+
"""Credential for ``api.BearerAuth``: refreshes on expiry and after a 401."""
|
|
471
|
+
|
|
472
|
+
kind = "clerk"
|
|
473
|
+
|
|
474
|
+
def __init__(self, store: TokenStore | None = None, issuer: str = ISSUER,
|
|
475
|
+
*, transport=None, clock=time.time):
|
|
476
|
+
self.store = store or TokenStore(issuer)
|
|
477
|
+
self.issuer = issuer
|
|
478
|
+
self._transport = transport
|
|
479
|
+
self._clock = clock
|
|
480
|
+
self._tokens = self.store.load()
|
|
481
|
+
self._discovery: Discovery | None = None
|
|
482
|
+
self._lock = threading.Lock()
|
|
483
|
+
|
|
484
|
+
@property
|
|
485
|
+
def signed_in(self) -> bool:
|
|
486
|
+
return self._tokens is not None
|
|
487
|
+
|
|
488
|
+
def _disc(self, http) -> Discovery:
|
|
489
|
+
if self._discovery is None:
|
|
490
|
+
self._discovery = discover(http, self.issuer)
|
|
491
|
+
return self._discovery
|
|
492
|
+
|
|
493
|
+
def token(self) -> str:
|
|
494
|
+
with self._lock:
|
|
495
|
+
if self._tokens is None:
|
|
496
|
+
raise ClerkError("Not signed in; run `netconverter login --clerk`")
|
|
497
|
+
if self._tokens.expired(self._clock()) and self._tokens.refresh_token:
|
|
498
|
+
self._refresh_locked()
|
|
499
|
+
return self._tokens.access_token
|
|
500
|
+
|
|
501
|
+
def refresh(self) -> bool:
|
|
502
|
+
with self._lock:
|
|
503
|
+
if self._tokens is None or not self._tokens.refresh_token:
|
|
504
|
+
return False
|
|
505
|
+
try:
|
|
506
|
+
self._refresh_locked()
|
|
507
|
+
return True
|
|
508
|
+
except ClerkError:
|
|
509
|
+
return False
|
|
510
|
+
|
|
511
|
+
def _refresh_locked(self) -> None:
|
|
512
|
+
with _http(self._transport) as http:
|
|
513
|
+
try:
|
|
514
|
+
tokens = refresh_tokens(http, self._disc(http), self._tokens.refresh_token)
|
|
515
|
+
except OAuthError as exc:
|
|
516
|
+
if exc.error in {"invalid_grant", "invalid_token", "unauthorized_client"}:
|
|
517
|
+
self.store.clear()
|
|
518
|
+
self._tokens = None
|
|
519
|
+
raise ClerkError(
|
|
520
|
+
"Your NetConverter sign-in expired; run `netconverter login --clerk`"
|
|
521
|
+
) from None
|
|
522
|
+
raise
|
|
523
|
+
self._tokens = tokens
|
|
524
|
+
self.store.save(tokens)
|
|
525
|
+
|
|
526
|
+
def userinfo(self) -> dict:
|
|
527
|
+
with _http(self._transport) as http:
|
|
528
|
+
disc = self._disc(http)
|
|
529
|
+
if not disc.userinfo_endpoint:
|
|
530
|
+
return {}
|
|
531
|
+
for attempt in range(2):
|
|
532
|
+
response = http.get(
|
|
533
|
+
disc.userinfo_endpoint,
|
|
534
|
+
headers={"Authorization": f"Bearer {self.token()}"},
|
|
535
|
+
)
|
|
536
|
+
if response.status_code == 401 and attempt == 0 and self.refresh():
|
|
537
|
+
continue
|
|
538
|
+
break
|
|
539
|
+
if response.is_error:
|
|
540
|
+
raise ClerkError(f"Could not read the signed-in profile (HTTP {response.status_code})")
|
|
541
|
+
try:
|
|
542
|
+
return response.json()
|
|
543
|
+
except ValueError:
|
|
544
|
+
return {}
|
|
545
|
+
|
|
546
|
+
def revoke_and_clear(self) -> bool:
|
|
547
|
+
tokens, self._tokens = self._tokens, None
|
|
548
|
+
if tokens:
|
|
549
|
+
try:
|
|
550
|
+
with _http(self._transport) as http:
|
|
551
|
+
disc = self._disc(http)
|
|
552
|
+
if disc.revocation_endpoint:
|
|
553
|
+
for value, hint in ((tokens.refresh_token, "refresh_token"),
|
|
554
|
+
(tokens.access_token, "access_token")):
|
|
555
|
+
if value:
|
|
556
|
+
http.post(disc.revocation_endpoint, data={
|
|
557
|
+
"token": value, "token_type_hint": hint, "client_id": CLIENT_ID})
|
|
558
|
+
except (httpx.HTTPError, ClerkError):
|
|
559
|
+
pass # local removal below is what logout guarantees
|
|
560
|
+
return self.store.clear()
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def profile_summary(info: dict) -> dict:
|
|
564
|
+
"""Non-secret identity fields for display; never tokens."""
|
|
565
|
+
out = {}
|
|
566
|
+
for key in ("email", "name", "preferred_username"):
|
|
567
|
+
if isinstance(info.get(key), str) and info[key]:
|
|
568
|
+
out[key] = _safe_text(info[key], 120)
|
|
569
|
+
orgs = []
|
|
570
|
+
for key in ("org_id", "org_slug", "org_name"):
|
|
571
|
+
if isinstance(info.get(key), str) and info[key]:
|
|
572
|
+
orgs.append(_safe_text(info[key], 120))
|
|
573
|
+
for org in info.get("organizations") or info.get("orgs") or []:
|
|
574
|
+
if isinstance(org, dict):
|
|
575
|
+
label = org.get("name") or org.get("slug") or org.get("id")
|
|
576
|
+
if isinstance(label, str):
|
|
577
|
+
orgs.append(_safe_text(label, 120))
|
|
578
|
+
if orgs:
|
|
579
|
+
out["organizations"] = sorted(set(orgs))
|
|
580
|
+
return out
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def login(
|
|
584
|
+
*, device: bool = False, issuer: str = ISSUER, transport=None,
|
|
585
|
+
notify=print, open_browser=webbrowser.open, store: TokenStore | None = None,
|
|
586
|
+
) -> TokenSet:
|
|
587
|
+
"""Sign in and store tokens. Refuses when no OS credential store exists."""
|
|
588
|
+
if store is None and not keyring_available():
|
|
589
|
+
raise ClerkError(
|
|
590
|
+
"No OS credential store is available; Clerk sign-in needs one to keep tokens off disk"
|
|
591
|
+
)
|
|
592
|
+
store = store or TokenStore(issuer)
|
|
593
|
+
with _http(transport) as http:
|
|
594
|
+
disc = discover(http, issuer)
|
|
595
|
+
tokens = (
|
|
596
|
+
device_login(http, disc, notify=notify)
|
|
597
|
+
if device
|
|
598
|
+
else browser_login(http, disc, open_browser=open_browser, notify=notify)
|
|
599
|
+
)
|
|
600
|
+
if not store.save(tokens):
|
|
601
|
+
raise ClerkError("Could not save the sign-in to the OS credential store")
|
|
602
|
+
return tokens
|