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.
@@ -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