multicloud-cli 0.1.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.
multicloud_cli/auth.py ADDED
@@ -0,0 +1,547 @@
1
+ """``mctl auth``: keep the API key or the SSO session in the OS keyring, or in a 0600 file when no keyring works.
2
+
3
+ No function here prints a key or a token. They leave this module only as return values
4
+ (get_key, fresh_access_token)."""
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import re
9
+ import stat
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Optional, Tuple
14
+ from urllib.parse import quote, urlparse
15
+
16
+ import click
17
+ import requests
18
+
19
+ from . import config, exitcodes, sso
20
+
21
+ try:
22
+ import fcntl
23
+ except ImportError: # Windows
24
+ fcntl = None
25
+ try:
26
+ import msvcrt
27
+ except ImportError:
28
+ msvcrt = None
29
+
30
+ SERVICE = "multicloud-api-key"
31
+ SSO_SERVICE = "multicloud-sso"
32
+ TIMEOUT_S = 30
33
+ REFRESH_SKEW_S = 60
34
+
35
+
36
+ class AuthError(Exception):
37
+ pass
38
+
39
+
40
+ def account(org: str) -> str:
41
+ return f"{org}/infra"
42
+
43
+
44
+ def credentials_file() -> Path:
45
+ return config.config_dir() / "credentials"
46
+
47
+
48
+ def _keyring():
49
+ """The keyring module, or None when it has no usable backend."""
50
+ try:
51
+ import keyring
52
+ from keyring.backends import fail
53
+ backend = keyring.get_keyring()
54
+ # keyrings.alt holds plaintext or weakly encrypted files: the 0600 file is no worse and is visible.
55
+ if isinstance(backend, fail.Keyring) or type(backend).__module__.startswith("keyrings.alt"):
56
+ return None
57
+ return keyring
58
+ except Exception:
59
+ return None
60
+
61
+
62
+ def _read_file(path: Path, acct: str) -> Optional[str]:
63
+ if not path.exists():
64
+ return None
65
+ mode = stat.S_IMODE(path.stat().st_mode)
66
+ # Windows has no POSIX modes (chmod only sets read-only): the profile folder ACL protects the file there.
67
+ if os.name != "nt" and mode & 0o077:
68
+ raise AuthError(f"{path} can be read by other users (mode {mode:o}). Run: chmod 600 {path}")
69
+ try:
70
+ data = json.loads(path.read_text())
71
+ except (OSError, ValueError):
72
+ raise AuthError(f"Could not read {path}.")
73
+ key = data.get(acct) if isinstance(data, dict) else None
74
+ return key or None
75
+
76
+
77
+ def _load_secret(service: str, acct: str) -> Tuple[Optional[str], Optional[str]]:
78
+ """(value, source): the OS keyring first, then the credentials file."""
79
+ ring = _keyring()
80
+ if ring is not None:
81
+ try:
82
+ value = ring.get_password(service, acct)
83
+ except Exception:
84
+ value = None
85
+ if value:
86
+ return value, "OS keyring"
87
+ path = credentials_file()
88
+ value = _read_file(path, acct)
89
+ if value:
90
+ return value, f"file {path}"
91
+ return None, None
92
+
93
+
94
+ def get_key(org: str) -> Tuple[Optional[str], Optional[str]]:
95
+ """(key, source). Order: environment, OS keyring, credentials file."""
96
+ key = os.environ.get("MULTICLOUD_API_KEY")
97
+ if key:
98
+ return key, "environment"
99
+ return _load_secret(SERVICE, account(org))
100
+
101
+
102
+ def _write_file(acct: str, key: Optional[str]) -> bool:
103
+ """Set (or remove, when key is None) one entry. Returns whether an entry was present or written."""
104
+ path = credentials_file()
105
+ data = {}
106
+ if path.exists():
107
+ _read_file(path, acct) # refuses a loose mode
108
+ try:
109
+ data = json.loads(path.read_text())
110
+ except (OSError, ValueError):
111
+ data = {}
112
+ had = acct in data
113
+ if key is None:
114
+ if not had:
115
+ return False
116
+ del data[acct]
117
+ else:
118
+ data[acct] = key
119
+ config.ensure_private_dir(strict=True)
120
+ # A temp file and a rename: a crash or a second login never leaves a cut-off file.
121
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
122
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
123
+ try:
124
+ with os.fdopen(fd, "w") as f:
125
+ f.write(json.dumps(data))
126
+ os.replace(tmp, path)
127
+ finally:
128
+ if tmp.exists():
129
+ tmp.unlink()
130
+ os.chmod(path, 0o600)
131
+ return True
132
+
133
+
134
+ def _store_secret(service: str, acct: str, value: str) -> str:
135
+ """Store one secret. Returns where: 'OS keyring' or 'file <path>'."""
136
+ ring = _keyring()
137
+ if ring is not None:
138
+ try:
139
+ ring.set_password(service, acct, value)
140
+ return "OS keyring"
141
+ except Exception:
142
+ # The keyring is read first: an old entry there would hide the value in the file.
143
+ try:
144
+ ring.delete_password(service, acct)
145
+ except Exception:
146
+ pass
147
+ _write_file(acct, value)
148
+ return f"file {credentials_file()}"
149
+
150
+
151
+ def _delete_secret(service: str, acct: str) -> list:
152
+ """Remove one secret from both places. Returns where it was removed from."""
153
+ removed = []
154
+ ring = _keyring()
155
+ if ring is not None:
156
+ try:
157
+ if ring.get_password(service, acct):
158
+ ring.delete_password(service, acct)
159
+ removed.append("OS keyring")
160
+ except Exception:
161
+ pass
162
+ if _write_file(acct, None):
163
+ removed.append(f"file {credentials_file()}")
164
+ return removed
165
+
166
+
167
+ def store_key(org: str, key: str) -> str:
168
+ """Store the key. Returns where: 'OS keyring' or 'file <path>'."""
169
+ return _store_secret(SERVICE, account(org), key)
170
+
171
+
172
+ def delete_key(org: str) -> list:
173
+ """Remove the key from both places. Returns where it was removed from."""
174
+ return _delete_secret(SERVICE, account(org))
175
+
176
+
177
+ def sso_account(issuer: str, part: str) -> str:
178
+ return f"{issuer}#{part}"
179
+
180
+
181
+ def store_sso(issuer: str, tokens: sso.Tokens) -> str:
182
+ """Store the refresh token, then the access token. Two entries: one keyring entry is small on some systems."""
183
+ where = _store_secret(SSO_SERVICE, sso_account(issuer, "refresh"), tokens.refresh)
184
+ _store_secret(SSO_SERVICE, sso_account(issuer, "access"), tokens.access)
185
+ return where
186
+
187
+
188
+ def load_sso(issuer: str) -> Optional[sso.Tokens]:
189
+ refresh, _ = _load_secret(SSO_SERVICE, sso_account(issuer, "refresh"))
190
+ if not refresh:
191
+ return None
192
+ access, _ = _load_secret(SSO_SERVICE, sso_account(issuer, "access"))
193
+ return sso.Tokens(access=access or "", refresh=refresh)
194
+
195
+
196
+ def sso_source(issuer: str) -> Optional[str]:
197
+ return _load_secret(SSO_SERVICE, sso_account(issuer, "refresh"))[1]
198
+
199
+
200
+ def delete_sso(issuer: str) -> list:
201
+ removed = _delete_secret(SSO_SERVICE, sso_account(issuer, "refresh"))
202
+ for where in _delete_secret(SSO_SERVICE, sso_account(issuer, "access")):
203
+ if where not in removed:
204
+ removed.append(where)
205
+ return removed
206
+
207
+
208
+ class SessionEnded(AuthError):
209
+ pass
210
+
211
+
212
+ @contextlib.contextmanager
213
+ def _refresh_lock():
214
+ """One refresh at a time across mctl processes. Keycloak may rotate the refresh token, and a refresh token
215
+ used twice ends the whole session, so two processes must not refresh from the same stored token."""
216
+ directory = config.ensure_private_dir()
217
+ fd = os.open(directory / ".refresh.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0), 0o600)
218
+ with os.fdopen(fd, "a+") as f:
219
+ if hasattr(os, "fchmod"): # not on Windows
220
+ try:
221
+ os.fchmod(f.fileno(), 0o600)
222
+ except OSError as ex: # another account's lock file: it holds no secret, so lock anyway
223
+ config.warn_once(f"cannot set {directory / '.refresh.lock'} to 0600 ({type(ex).__name__}).")
224
+ if fcntl is not None:
225
+ fcntl.flock(f, fcntl.LOCK_EX)
226
+ elif msvcrt is not None: # pragma: no cover (Windows)
227
+ f.seek(0)
228
+ for attempt in range(6): # LK_LOCK itself gives up after about 10 s
229
+ try:
230
+ msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
231
+ break
232
+ except OSError:
233
+ if attempt == 5:
234
+ raise AuthError("Another mctl is refreshing the sign-in. Try again in a minute.")
235
+ try:
236
+ yield
237
+ finally:
238
+ if fcntl is not None:
239
+ fcntl.flock(f, fcntl.LOCK_UN)
240
+ elif msvcrt is not None: # pragma: no cover (Windows)
241
+ f.seek(0)
242
+ msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
243
+
244
+
245
+ def _valid(access: str) -> bool:
246
+ return bool(access) and sso.expires_at(access) - time.time() > REFRESH_SKEW_S
247
+
248
+
249
+ def fresh_access_token(issuer: str, rejected: Optional[str] = None) -> str:
250
+ """The access token of the stored SSO session; refreshed when it expires within a minute, or when it is
251
+ the ``rejected`` one (the API answered 401). The refresh response may hold a new refresh token
252
+ (rotation): it replaces the old one, and is written before the access token."""
253
+ tokens = load_sso(issuer)
254
+ if tokens is None:
255
+ raise SessionEnded("You are not signed in. Run: mctl auth login")
256
+ if _valid(tokens.access) and tokens.access != rejected:
257
+ return tokens.access
258
+ with _refresh_lock():
259
+ # Another process may have refreshed while this one waited for the lock.
260
+ tokens = load_sso(issuer)
261
+ if tokens is None:
262
+ raise SessionEnded("You are not signed in. Run: mctl auth login")
263
+ if _valid(tokens.access) and tokens.access != rejected:
264
+ return tokens.access
265
+ try:
266
+ new = sso.refresh(issuer, tokens.refresh)
267
+ except sso.SsoError as ex:
268
+ if ex.code == "invalid_grant":
269
+ raise SessionEnded("Your session ended. Run: mctl auth login (or mctl auth logout --sso to use a stored API key)")
270
+ raise AuthError(str(ex))
271
+ try:
272
+ store_sso(issuer, new)
273
+ except AuthError as ex:
274
+ # The old refresh token may be dead already (rotation): say that the session was not saved.
275
+ raise AuthError(f"{ex} The refreshed sign-in could not be saved. Run: mctl auth login")
276
+ return new.access
277
+
278
+
279
+ def check_key(org: str, api_url: str, key: str) -> Tuple[bool, str]:
280
+ """GET the list of cloud accounts with this key. Returns (ok, error text)."""
281
+ url = f"{api_url}/api/v1/orgs/{quote(org, safe='')}/cloud-accounts"
282
+ try:
283
+ r = requests.request("GET", url, timeout=TIMEOUT_S, headers={"X-API-Key": key},
284
+ allow_redirects=False)
285
+ except requests.RequestException as ex:
286
+ return False, f"Could not reach the API: {type(ex).__name__}"
287
+ if r.status_code == 200:
288
+ return True, ""
289
+ return False, f"The API refused the key (HTTP {r.status_code})."
290
+
291
+
292
+ def need_org(option: Optional[str]) -> str:
293
+ org = config.get_org(option)
294
+ if not org:
295
+ click.echo("Error: name the organization with --org or MULTICLOUD_ORG.", err=True)
296
+ sys.exit(1)
297
+ return org
298
+
299
+
300
+ def _check_hosts(url: str, issuer: str) -> None:
301
+ for what, u in (("API", url), ("sign-in", issuer)):
302
+ host = urlparse(u).hostname or ""
303
+ if host not in ("localhost", "127.0.0.1", "::1") and host != "multicloud.io" \
304
+ and not host.endswith(".multicloud.io"):
305
+ click.echo(f"Warning: {u} is not a multicloud.io address. Your credentials go to that {what} host.", err=True)
306
+
307
+
308
+ @click.group()
309
+ def auth():
310
+ """Sign in with your browser, or store an API key, and check the result."""
311
+
312
+
313
+ @auth.command()
314
+ @click.option("--org", default=None, help="Organization slug (default: MULTICLOUD_ORG, saved, or the one in your sign-in)")
315
+ @click.option("--api-url", default=None, help="API URL (default: MULTICLOUD_API_URL or saved)")
316
+ @click.option("--issuer-url", default=None, help="Keycloak realm URL (default: MULTICLOUD_ISSUER_URL, saved, or auth.multicloud.io)")
317
+ @click.option("--no-browser", is_flag=True, help="Sign in with a code on another device (for SSH and containers)")
318
+ @click.option("--api-key", "use_key", is_flag=True, help="Store an API key instead of signing in with SSO")
319
+ @click.option("--key-stdin", is_flag=True, help="Read the API key from one line of standard input (implies --api-key)")
320
+ def login(org, api_url, issuer_url, no_browser, use_key, key_stdin):
321
+ """Sign in with SSO (browser), or with --api-key store a checked API key. Nothing secret is shown."""
322
+ try:
323
+ url = config.check_api_url(api_url) if api_url else config.get_api_url()
324
+ issuer = config.check_api_url(issuer_url, "issuer URL") if issuer_url else config.get_issuer()
325
+ except config.ConfigError as ex:
326
+ click.echo(f"Error: {ex} Nothing stored.", err=True)
327
+ sys.exit(1)
328
+ if use_key or key_stdin:
329
+ _login_key(org, url, key_stdin)
330
+ return
331
+ _check_hosts(url, issuer)
332
+ try:
333
+ tokens = (sso.login_device if no_browser else sso.login_browser)(
334
+ issuer, lambda text: click.echo(text, err=True)) # the URL and code are for the user, not for a pipe
335
+ except sso.SsoError as ex:
336
+ click.echo(f"Error: {ex}", err=True)
337
+ sys.exit(1)
338
+ orgs = sso.orgs_in_token(tokens.access)
339
+ org = config.get_org(org) if org else None
340
+ if org and org not in orgs:
341
+ listed = ", ".join(orgs) or "none"
342
+ click.echo(f"Error: you are not a member of '{org}' (your organizations: {listed}). Nothing stored.", err=True)
343
+ sys.exit(1)
344
+ probe = _probe(url, tokens.access, org or (orgs[0] if orgs else None))
345
+ if probe == 401:
346
+ click.echo("Error: the API did not accept this sign-in (HTTP 401). Nothing stored. "
347
+ "Ask Multicloud support whether the API accepts the 'multicloud-cli' client.", err=True)
348
+ sys.exit(exitcodes.AUTH)
349
+ if probe is not None and probe != 200:
350
+ click.echo(f"Warning: the API answered HTTP {probe} to a test call. Check with: mctl auth status", err=True)
351
+ try:
352
+ where = store_sso(issuer, tokens)
353
+ except AuthError as ex:
354
+ click.echo(f"Error: {ex} Nothing stored.", err=True)
355
+ sys.exit(1)
356
+ config.save(org=org, api_url=api_url and url, issuer=issuer_url and issuer)
357
+ claims = sso.decode_claims(tokens.access)
358
+ who = claims.get("email") or claims.get("preferred_username") or "your account"
359
+ click.echo(f"Signed in as {who}. Session stored in {where}.")
360
+ if not orgs:
361
+ click.echo("Warning: your sign-in holds no organization. Ask an owner to add you.", err=True)
362
+ else:
363
+ click.echo(f"Organizations: {', '.join(orgs)}." + ("" if len(orgs) == 1 else " Name one with --org."))
364
+
365
+
366
+ def _probe(url: str, access: str, org: Optional[str]) -> Optional[int]:
367
+ """One read with the new token, before it is stored. The HTTP status, or None when there is no answer or no
368
+ organization to ask about (a network problem must not block a sign-in)."""
369
+ if not org:
370
+ return None
371
+ try:
372
+ r = requests.request("GET", f"{url}/api/v1/orgs/{quote(org, safe='')}/pool-classes", timeout=TIMEOUT_S,
373
+ headers={"Authorization": f"Bearer {access}"}, allow_redirects=False)
374
+ except requests.RequestException:
375
+ return None
376
+ return r.status_code
377
+
378
+
379
+ def _key_exit_code(error: str) -> int:
380
+ """3 when the API refused the key (HTTP 401 or 403), else 1."""
381
+ m = re.search(r"refused the key \(HTTP (\d+)\)", error)
382
+ return exitcodes.for_status(int(m.group(1))) if m else exitcodes.ERROR
383
+
384
+
385
+ def _login_key(org, url, key_stdin):
386
+ org = need_org(org)
387
+ host = urlparse(url).hostname or ""
388
+ if host not in ("localhost", "127.0.0.1", "::1") and host != "multicloud.io" \
389
+ and not host.endswith(".multicloud.io"):
390
+ click.echo(f"Warning: {url} is not a multicloud.io address. The key goes to that host.", err=True)
391
+ if key_stdin:
392
+ key = sys.stdin.readline()
393
+ else:
394
+ key = click.prompt("API key", hide_input=True)
395
+ key = key.strip()
396
+ if not key:
397
+ click.echo("Error: no API key given. Nothing stored.", err=True)
398
+ sys.exit(1)
399
+ ok, error = check_key(org, url, key)
400
+ if not ok:
401
+ click.echo(f"{error} Nothing stored.", err=True)
402
+ sys.exit(_key_exit_code(error))
403
+ try:
404
+ where = store_key(org, key)
405
+ except AuthError as ex:
406
+ click.echo(f"Error: {ex} Nothing stored.", err=True)
407
+ sys.exit(1)
408
+ config.save(org=org, api_url=url)
409
+ click.echo(f"Stored the API key for {org} in {where}. It can read the cloud accounts of {org}.")
410
+
411
+
412
+ def _expiry_text(access: str) -> str:
413
+ exp = sso.expires_at(access)
414
+ if not exp:
415
+ return "unknown"
416
+ left = int(exp - time.time())
417
+ stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(exp))
418
+ return f"{stamp} (in {left // 60} min)" if left > 0 else f"{stamp} (expired; refreshed on the next call)"
419
+
420
+
421
+ @auth.command()
422
+ @click.option("--org", default=None)
423
+ @click.option("--json", "as_json", is_flag=True, help="Print method, api_url, org, orgs, expires and works as JSON")
424
+ def status(org, as_json):
425
+ """Show how you are signed in and check that it works."""
426
+ say = lambda text: click.echo(text, err=as_json) # noqa: E731
427
+ info = {"method": None, "api_url": None, "org": None, "orgs": [], "expires": None, "works": None}
428
+
429
+ def emit(works=None):
430
+ info["works"] = works
431
+ if as_json:
432
+ click.echo(json.dumps(info, indent=2, sort_keys=True))
433
+ try:
434
+ url = config.get_api_url()
435
+ issuer = config.get_issuer()
436
+ except config.ConfigError as ex:
437
+ click.echo(f"Error: {ex}", err=True)
438
+ sys.exit(1)
439
+ say(f"API URL: {url}")
440
+ info["api_url"] = url
441
+ slug = config.get_org(org)
442
+ info["org"] = slug
443
+ env_key = bool(os.environ.get("MULTICLOUD_API_KEY"))
444
+ try:
445
+ tokens = load_sso(issuer)
446
+ stored, source = get_key(slug) if slug and not env_key else (None, None)
447
+ except AuthError as ex:
448
+ click.echo(f"Error: {ex}", err=True)
449
+ sys.exit(1)
450
+ if env_key:
451
+ say("Method: API key from MULTICLOUD_API_KEY (wins over a stored sign-in)")
452
+ info["method"] = "api-key-env"
453
+ elif tokens:
454
+ say("Method: SSO")
455
+ info["method"] = "sso"
456
+ elif stored:
457
+ say(f"Method: API key from {source}")
458
+ info["method"] = "api-key"
459
+ else:
460
+ say("Method: none. Run: mctl auth login")
461
+ emit(False)
462
+ sys.exit(exitcodes.AUTH)
463
+ if tokens:
464
+ claims = sso.decode_claims(tokens.access)
465
+ say(f"SSO user: {claims.get('email') or claims.get('preferred_username') or 'unknown'}"
466
+ f" (stored in {sso_source(issuer)}; the refresh token is kept)")
467
+ say(f"Access token expires: {_expiry_text(tokens.access)}")
468
+ say(f"Organizations: {', '.join(sso.orgs_in_token(tokens.access)) or 'none'}")
469
+ info["orgs"] = sso.orgs_in_token(tokens.access)
470
+ exp = sso.expires_at(tokens.access)
471
+ info["expires"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(exp)) if exp else None
472
+ if tokens and not env_key:
473
+ if stored:
474
+ say(f"Stored API key for {slug}: {source} (not used while an SSO sign-in exists)")
475
+ orgs = sso.orgs_in_token(tokens.access)
476
+ explicit = config.org_is_explicit(org)
477
+ if len(orgs) > 1 and not explicit and slug not in orgs:
478
+ say("You are signed in, but no organization is chosen. To check an API call, name one: "
479
+ "mctl auth status --org <team-id> (save one with: mctl auth login --org <team-id>)")
480
+ info["org"] = None
481
+ emit()
482
+ return
483
+ from . import api # api imports this module
484
+ try:
485
+ api.call("GET", org, "/pool-classes")
486
+ except api.ApiError as ex:
487
+ click.echo(f"The sign-in does not work: {ex}", err=True)
488
+ emit(False)
489
+ sys.exit(ex.exit_code)
490
+ say("The sign-in works.")
491
+ emit(True)
492
+ return
493
+ key = os.environ.get("MULTICLOUD_API_KEY") or stored
494
+ if not slug:
495
+ click.echo("Name the organization with --org or MULTICLOUD_ORG to check the key.", err=True)
496
+ emit()
497
+ sys.exit(1)
498
+ ok, error = check_key(slug, url, key)
499
+ if not ok:
500
+ click.echo(error, err=True)
501
+ emit(False)
502
+ sys.exit(_key_exit_code(error))
503
+ say("The key works.")
504
+ emit(True)
505
+
506
+
507
+ @auth.command()
508
+ @click.option("--org", default=None)
509
+ @click.option("--sso", "only_sso", is_flag=True, help="Remove only the SSO sign-in")
510
+ @click.option("--api-key", "only_key", is_flag=True, help="Remove only the stored API key")
511
+ def logout(org, only_sso, only_key):
512
+ """End the SSO session at Keycloak and remove it, and remove the stored key.
513
+
514
+ The CLI shares the Keycloak browser session, so this signs you out of Keycloak in your browser too."""
515
+ do_sso, do_key = (only_sso or not only_key), (only_key or not only_sso)
516
+ try:
517
+ if do_sso:
518
+ try:
519
+ issuer = config.get_issuer()
520
+ except config.ConfigError as ex:
521
+ click.echo(f"Error: {ex}", err=True)
522
+ sys.exit(1)
523
+ tokens = load_sso(issuer)
524
+ if tokens:
525
+ try:
526
+ sso.revoke(issuer, tokens.refresh)
527
+ click.echo("Ended the session at the sign-in server.")
528
+ except sso.SsoError as ex:
529
+ click.echo(f"Warning: could not end the session at the sign-in server ({ex}). "
530
+ "It stays valid until it expires.", err=True)
531
+ click.echo(f"Removed the SSO sign-in from {', '.join(delete_sso(issuer)) or 'nowhere'}.")
532
+ elif only_sso:
533
+ click.echo("No SSO sign-in stored.")
534
+ if do_key:
535
+ slug = config.get_org(org)
536
+ if slug:
537
+ removed = delete_key(slug)
538
+ if removed:
539
+ click.echo(f"Removed the API key for {slug} from {', '.join(removed)}.")
540
+ elif only_key:
541
+ click.echo(f"No stored API key for {slug}.")
542
+ elif only_key:
543
+ click.echo("Error: name the organization with --org or MULTICLOUD_ORG.", err=True)
544
+ sys.exit(1)
545
+ except AuthError as ex:
546
+ click.echo(f"Error: {ex}", err=True)
547
+ sys.exit(1)