ocmo-cli 0.8.19__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.
Files changed (81) hide show
  1. LICENSE +201 -0
  2. NOTICE +16 -0
  3. ocmo_cli/__init__.py +3 -0
  4. ocmo_cli/__main__.py +4 -0
  5. ocmo_cli/_address.py +182 -0
  6. ocmo_cli/_audit_item.py +33 -0
  7. ocmo_cli/_auth_resolve.py +66 -0
  8. ocmo_cli/_body_payload.py +203 -0
  9. ocmo_cli/_click_groups.py +227 -0
  10. ocmo_cli/_client.py +351 -0
  11. ocmo_cli/_client_commands.py +35 -0
  12. ocmo_cli/_command_registry.py +29 -0
  13. ocmo_cli/_commands_map.py +65 -0
  14. ocmo_cli/_config.py +183 -0
  15. ocmo_cli/_config_content.py +43 -0
  16. ocmo_cli/_delete_item_output.py +129 -0
  17. ocmo_cli/_diff_input.py +144 -0
  18. ocmo_cli/_dry_run.py +292 -0
  19. ocmo_cli/_errors.py +151 -0
  20. ocmo_cli/_exit.py +13 -0
  21. ocmo_cli/_export_metadata.py +47 -0
  22. ocmo_cli/_generated_registry.py +45 -0
  23. ocmo_cli/_get_cast_output.py +52 -0
  24. ocmo_cli/_get_item_list.py +23 -0
  25. ocmo_cli/_globalpermission_output.py +98 -0
  26. ocmo_cli/_import_metadata.py +140 -0
  27. ocmo_cli/_import_plan.py +221 -0
  28. ocmo_cli/_item_output.py +223 -0
  29. ocmo_cli/_lazy_mixin.py +29 -0
  30. ocmo_cli/_ls_wide.py +286 -0
  31. ocmo_cli/_mutating.py +39 -0
  32. ocmo_cli/_notice.py +17 -0
  33. ocmo_cli/_options.py +77 -0
  34. ocmo_cli/_output.py +424 -0
  35. ocmo_cli/_output_manifest.py +332 -0
  36. ocmo_cli/_resolve_draft.py +50 -0
  37. ocmo_cli/_resolve_options.py +143 -0
  38. ocmo_cli/_resolve_output.py +228 -0
  39. ocmo_cli/_resolve_parameters_output.py +72 -0
  40. ocmo_cli/_resolve_series.py +89 -0
  41. ocmo_cli/_resolve_series_chart.py +409 -0
  42. ocmo_cli/_resolve_write.py +185 -0
  43. ocmo_cli/_resolver_output.py +34 -0
  44. ocmo_cli/_resource_aliases.py +22 -0
  45. ocmo_cli/_sdk_dispatch.py +310 -0
  46. ocmo_cli/_typing.py +27 -0
  47. ocmo_cli/_version_output.py +39 -0
  48. ocmo_cli/_whoami_output.py +43 -0
  49. ocmo_cli/commands/__init__.py +1 -0
  50. ocmo_cli/commands/api.py +125 -0
  51. ocmo_cli/commands/api_health.py +12 -0
  52. ocmo_cli/commands/apply.py +153 -0
  53. ocmo_cli/commands/auth.py +786 -0
  54. ocmo_cli/commands/can_i.py +51 -0
  55. ocmo_cli/commands/completion.py +59 -0
  56. ocmo_cli/commands/config_cmd.py +235 -0
  57. ocmo_cli/commands/describe.py +109 -0
  58. ocmo_cli/commands/diff.py +86 -0
  59. ocmo_cli/commands/edit.py +200 -0
  60. ocmo_cli/commands/export.py +242 -0
  61. ocmo_cli/commands/generated.py +907 -0
  62. ocmo_cli/commands/gp_move.py +108 -0
  63. ocmo_cli/commands/import_.py +595 -0
  64. ocmo_cli/commands/item_relocate.py +187 -0
  65. ocmo_cli/commands/ls.py +481 -0
  66. ocmo_cli/commands/resolve.py +658 -0
  67. ocmo_cli/commands/resolve_draft.py +102 -0
  68. ocmo_cli/commands/resolve_parameters.py +65 -0
  69. ocmo_cli/commands/resolve_series.py +109 -0
  70. ocmo_cli/commands/schema.py +112 -0
  71. ocmo_cli/commands/timeline.py +135 -0
  72. ocmo_cli/commands/version_cmd.py +102 -0
  73. ocmo_cli/commands/whoami.py +12 -0
  74. ocmo_cli/main.py +241 -0
  75. ocmo_cli/output_formats.yaml +295 -0
  76. ocmo_cli-0.8.19.dist-info/METADATA +169 -0
  77. ocmo_cli-0.8.19.dist-info/RECORD +81 -0
  78. ocmo_cli-0.8.19.dist-info/WHEEL +4 -0
  79. ocmo_cli-0.8.19.dist-info/entry_points.txt +2 -0
  80. ocmo_cli-0.8.19.dist-info/licenses/LICENSE +201 -0
  81. ocmo_cli-0.8.19.dist-info/licenses/NOTICE +16 -0
@@ -0,0 +1,786 @@
1
+ """ocmo auth — OIDC authentication commands.
2
+
3
+ login: Device-authorization flow by default; optional browser PKCE with --browser.
4
+ status: Show active context, identity, token expiry.
5
+ logout: Remove cached tokens for the context.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import time
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ import click
17
+
18
+ from .._auth_resolve import ResolvedOidc, auth_entry_from_resolved, resolve_oidc_settings
19
+ from .._click_groups import ConfigPathHelpGroup
20
+ from .._client import OcmoCtx, _fetch_oidc_from_server, build_ocmo_config
21
+ from .._config import AuthEntry, Context, _config_path, load_config
22
+ from .._errors import sdk_command
23
+ from .._options import output_option
24
+ from .._output import as_dict, emit, err, warn
25
+
26
+ if TYPE_CHECKING:
27
+ from ocmo.config import OcmoConfig
28
+
29
+ from .._config import CliConfig
30
+
31
+ _AUTH_HELP = """\
32
+ Authenticate with the OCMO server.
33
+
34
+ For CI/CD, set OCMO_TOKEN or OCMO_CLIENT_ID + OCMO_CLIENT_SECRET instead of logging in.
35
+ """
36
+
37
+
38
+ @click.group("auth", cls=ConfigPathHelpGroup, help=_AUTH_HELP)
39
+ def auth_group() -> None:
40
+ """Authenticate with the OCMO server."""
41
+
42
+
43
+ _LOGIN_HELP = """\
44
+ Log in interactively via OIDC (device code by default).
45
+
46
+ Use --browser for Authorization Code + PKCE on a fixed loopback port.
47
+ Tokens are cached in OCMO_CACHE_DIR.
48
+ """
49
+
50
+ # Loopback PKCE callback (ocmo auth login --browser). Dex must register this URI exactly.
51
+ # High port avoids common dev services (8080, 8000, 3000, 9000, …).
52
+ CLI_CALLBACK_HOST = "127.0.0.1"
53
+ CLI_CALLBACK_PATH = "/callback"
54
+ CLI_CALLBACK_PORT = 47291
55
+
56
+
57
+ @auth_group.command("login", help=_LOGIN_HELP)
58
+ @click.option(
59
+ "--browser",
60
+ is_flag=True,
61
+ default=False,
62
+ help=f"Use browser PKCE login (loopback callback on port {CLI_CALLBACK_PORT}).",
63
+ )
64
+ @click.option("--context", default=None, help="Context to authenticate (default: current).")
65
+ @click.pass_obj
66
+ @sdk_command
67
+ def login_cmd(ctx: OcmoCtx, browser: bool, context: str | None) -> None:
68
+ cfg = load_config()
69
+ ctx_name = context or os.environ.get("OCMO_CONTEXT") or cfg.current_context
70
+ active = cfg.contexts.get(ctx_name) if ctx_name else cfg.active_context()
71
+
72
+ sdk_cfg = build_ocmo_config(
73
+ skip_version_check=ctx.skip_version_check,
74
+ context_name=ctx_name,
75
+ validate_auth=False,
76
+ )
77
+ if not sdk_cfg.server:
78
+ err("No server configured. Set OCMO_SERVER or run:\n" " ocmo config set server https://ocmo.example.com")
79
+ raise SystemExit(1)
80
+
81
+ use_device = not browser
82
+
83
+ try:
84
+ auth_entry = resolve_login_auth(cfg, active, sdk_cfg, device_flow=use_device)
85
+ except ValueError as exc:
86
+ err(str(exc))
87
+ raise SystemExit(1)
88
+
89
+ if (
90
+ active
91
+ and active.auth
92
+ and active.auth not in cfg.auths
93
+ and (os.environ.get("OCMO_CLIENT_ID") or os.environ.get("OCMO_OIDC_ISSUER"))
94
+ ):
95
+ warn(
96
+ f"Context references auth {active.auth!r} but no auths.{active.auth} block "
97
+ "exists in the config file; using environment / API OIDC settings."
98
+ )
99
+
100
+ if use_device:
101
+ _device_flow(auth_entry, sdk_cfg)
102
+ else:
103
+ _browser_flow(auth_entry, sdk_cfg)
104
+
105
+
106
+ def resolve_login_auth(
107
+ cfg: CliConfig,
108
+ active: Context | None,
109
+ sdk_cfg: OcmoConfig,
110
+ *,
111
+ device_flow: bool = False,
112
+ ) -> AuthEntry:
113
+ """Resolve OIDC client settings for login from env, config file, and API hints."""
114
+ file_entry: AuthEntry | None = None
115
+ if active and active.auth:
116
+ file_entry = cfg.auths.get(active.auth)
117
+
118
+ server = os.environ.get("OCMO_SERVER") or (active.server if active else "") or cfg.server() or sdk_cfg.server or ""
119
+ resolved = resolve_oidc_settings(
120
+ server=server or None,
121
+ entry=file_entry,
122
+ fetch_server_oidc=_fetch_oidc_from_server,
123
+ )
124
+ issuer = sdk_cfg.oidc_issuer or resolved.issuer
125
+ client_id = sdk_cfg.client_id or resolved.client_id
126
+ client_secret = sdk_cfg.client_secret or resolved.client_secret
127
+
128
+ if not issuer or not client_id:
129
+ config_path = _config_path()
130
+ auth_name = active.auth if active else None
131
+ if device_flow:
132
+ lines = [
133
+ "Could not discover OIDC settings for device login.",
134
+ "",
135
+ "Set the server URL:",
136
+ " ocmo config set server https://ocmo.example.com",
137
+ " # or OCMO_SERVER",
138
+ ]
139
+ else:
140
+ lines = [
141
+ "OIDC issuer and client_id are required for login.",
142
+ "",
143
+ "Set environment variables:",
144
+ " OCMO_OIDC_ISSUER, OCMO_CLIENT_ID",
145
+ " OCMO_CLIENT_SECRET (when the client requires a secret)",
146
+ "",
147
+ f"Or add an auths: entry to {config_path}:",
148
+ " ocmo config set-auth <auth-name> --issuer <url> --client-id <id>",
149
+ " # or ocmo config set-context <ctx> --auth <auth-name> (creates mode: oidc)",
150
+ ]
151
+ if auth_name and auth_name not in cfg.auths:
152
+ lines.append(f"\nContext references auth {auth_name!r} but that entry is missing.")
153
+ raise ValueError("\n".join(lines))
154
+
155
+ return auth_entry_from_resolved(ResolvedOidc(issuer=issuer, client_id=client_id, client_secret=client_secret))
156
+
157
+
158
+ def _pkce_redirect_uri(port: int = CLI_CALLBACK_PORT) -> str:
159
+ return f"http://{CLI_CALLBACK_HOST}:{port}{CLI_CALLBACK_PATH}"
160
+
161
+
162
+ def _ensure_callback_port_available(port: int = CLI_CALLBACK_PORT) -> int:
163
+ """Verify the fixed PKCE callback port is free before opening the browser."""
164
+ import socket
165
+
166
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
167
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
168
+ try:
169
+ sock.bind((CLI_CALLBACK_HOST, port))
170
+ except OSError as exc:
171
+ raise RuntimeError(
172
+ f"Cannot bind OAuth callback on {_pkce_redirect_uri(port)} ({exc}). "
173
+ "Stop the process using that port, or run without --browser "
174
+ "(default device-code login)."
175
+ ) from exc
176
+ return port
177
+
178
+
179
+ def _wait_for_authorization_code(port: int, state: str, *, timeout: float = 300.0) -> str:
180
+ """Run a one-shot loopback HTTP server and return the OAuth authorization code."""
181
+ import threading
182
+ import time
183
+ from http.server import BaseHTTPRequestHandler, HTTPServer
184
+ from urllib.parse import parse_qs, urlparse
185
+
186
+ result: dict[str, str] = {}
187
+ done = threading.Event()
188
+
189
+ class _CallbackHandler(BaseHTTPRequestHandler):
190
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A003
191
+ return
192
+
193
+ def do_GET(self) -> None: # noqa: N802
194
+ parsed = urlparse(self.path)
195
+ if parsed.path != CLI_CALLBACK_PATH:
196
+ self.send_response(404)
197
+ self.end_headers()
198
+ return
199
+
200
+ params = parse_qs(parsed.query)
201
+ if params.get("state", [""])[0] != state:
202
+ result["error"] = "OAuth state mismatch (possible CSRF). Try login again."
203
+ self._respond(400, "Invalid state. Return to the CLI and try again.")
204
+ done.set()
205
+ return
206
+
207
+ if oauth_error := params.get("error", [""])[0]:
208
+ desc = params.get("error_description", [oauth_error])[0]
209
+ result["error"] = str(desc)
210
+ self._respond(400, f"Authorization failed: {desc}")
211
+ done.set()
212
+ return
213
+
214
+ code = params.get("code", [""])[0]
215
+ if not code:
216
+ result["error"] = "Authorization response missing code."
217
+ self._respond(400, "Missing authorization code.")
218
+ done.set()
219
+ return
220
+
221
+ result["code"] = code
222
+ self._respond(200, "Login successful. You can close this tab and return to the CLI.")
223
+ done.set()
224
+
225
+ def _respond(self, status: int, message: str) -> None:
226
+ body = ("<!DOCTYPE html><html><body>" f"<p>{message}</p>" "</body></html>").encode()
227
+ self.send_response(status)
228
+ self.send_header("Content-Type", "text/html; charset=utf-8")
229
+ self.send_header("Content-Length", str(len(body)))
230
+ self.end_headers()
231
+ self.wfile.write(body)
232
+
233
+ server = HTTPServer((CLI_CALLBACK_HOST, port), _CallbackHandler)
234
+ server.timeout = 1.0
235
+
236
+ def _serve() -> None:
237
+ deadline = time.monotonic() + timeout
238
+ while not done.is_set() and time.monotonic() < deadline:
239
+ server.handle_request()
240
+ server.server_close()
241
+
242
+ thread = threading.Thread(target=_serve, daemon=True)
243
+ thread.start()
244
+ done.wait(timeout=timeout)
245
+ thread.join(timeout=2)
246
+
247
+ if "error" in result:
248
+ raise RuntimeError(result["error"])
249
+ if "code" not in result:
250
+ raise RuntimeError("Timed out waiting for browser authorization.")
251
+ return result["code"]
252
+
253
+
254
+ def _exchange_pkce_code(
255
+ token_endpoint: str,
256
+ *,
257
+ code: str,
258
+ redirect_uri: str,
259
+ client_id: str,
260
+ code_verifier: str,
261
+ client_secret: str = "",
262
+ ) -> tuple[str, int]:
263
+ import httpx
264
+
265
+ token_data: dict[str, str] = {
266
+ "grant_type": "authorization_code",
267
+ "code": code,
268
+ "redirect_uri": redirect_uri,
269
+ "client_id": client_id,
270
+ "code_verifier": code_verifier,
271
+ }
272
+ if client_secret:
273
+ token_data["client_secret"] = client_secret
274
+
275
+ resp = httpx.post(str(token_endpoint), data=token_data, timeout=30)
276
+ if resp.status_code != 200:
277
+ body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
278
+ detail = body.get("error_description") or body.get("error") or resp.text
279
+ raise RuntimeError(f"Token exchange failed: {detail}")
280
+ payload = resp.json()
281
+ access_token = payload.get("access_token")
282
+ if not access_token:
283
+ raise RuntimeError("Token endpoint returned no access_token.")
284
+ return str(access_token), int(payload.get("expires_in", 3600))
285
+
286
+
287
+ def _browser_flow(auth_entry: AuthEntry, sdk_cfg: OcmoConfig) -> None:
288
+ """Authorization Code + PKCE via browser and a local loopback callback server."""
289
+ try:
290
+ import base64
291
+ import hashlib
292
+ import secrets
293
+ import urllib.parse
294
+ import webbrowser
295
+
296
+ from ocmo.auth import fetch_oidc_discovery, store_oidc_access_token
297
+
298
+ port = _ensure_callback_port_available()
299
+ redirect_uri = _pkce_redirect_uri(port)
300
+ state = secrets.token_urlsafe(16)
301
+
302
+ code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
303
+ code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
304
+
305
+ discovery = fetch_oidc_discovery(auth_entry.issuer)
306
+ auth_endpoint = discovery.get("authorization_endpoint")
307
+ token_endpoint = discovery.get("token_endpoint")
308
+ if not auth_endpoint or not token_endpoint:
309
+ err(f"OIDC provider at {auth_entry.issuer!r} is missing " "authorization_endpoint or token_endpoint.")
310
+ raise SystemExit(1)
311
+
312
+ scope = sdk_cfg.oidc_scope or "openid"
313
+ client_secret = sdk_cfg.client_secret or auth_entry.client_secret
314
+
315
+ auth_url = (
316
+ str(auth_endpoint)
317
+ + "?"
318
+ + urllib.parse.urlencode(
319
+ {
320
+ "response_type": "code",
321
+ "client_id": auth_entry.client_id,
322
+ "redirect_uri": redirect_uri,
323
+ "scope": scope,
324
+ "state": state,
325
+ "code_challenge": code_challenge,
326
+ "code_challenge_method": "S256",
327
+ }
328
+ )
329
+ )
330
+
331
+ print(f"Listening for OAuth callback on {redirect_uri}")
332
+ print("Opening browser for login...")
333
+ if not webbrowser.open(auth_url):
334
+ print(f"If the browser did not open, visit:\n {auth_url}")
335
+
336
+ code = _wait_for_authorization_code(port, state)
337
+ access_token, expires_in = _exchange_pkce_code(
338
+ str(token_endpoint),
339
+ code=code,
340
+ redirect_uri=redirect_uri,
341
+ client_id=auth_entry.client_id,
342
+ code_verifier=code_verifier,
343
+ client_secret=client_secret,
344
+ )
345
+ store_oidc_access_token(sdk_cfg, access_token, expires_in)
346
+ _remove_legacy_token_file(sdk_cfg.cache_dir)
347
+ print("Logged in successfully.")
348
+ except SystemExit:
349
+ raise
350
+ except Exception as exc:
351
+ err(f"Browser login error: {exc}")
352
+ raise SystemExit(1)
353
+
354
+
355
+ def _device_flow(auth_entry: AuthEntry, sdk_cfg: OcmoConfig) -> None:
356
+ """Device Authorization Grant (RFC 8628) via OIDC discovery."""
357
+ try:
358
+ import time
359
+
360
+ import httpx
361
+ from ocmo.auth import fetch_oidc_discovery, store_oidc_access_token
362
+
363
+ discovery = fetch_oidc_discovery(auth_entry.issuer)
364
+ device_endpoint = discovery.get("device_authorization_endpoint")
365
+ token_endpoint = discovery.get("token_endpoint")
366
+ if not device_endpoint or not token_endpoint:
367
+ err(
368
+ f"OIDC provider at {auth_entry.issuer!r} does not support the device flow "
369
+ "(missing device_authorization_endpoint or token_endpoint)."
370
+ )
371
+ raise SystemExit(1)
372
+
373
+ client_secret = sdk_cfg.client_secret or auth_entry.client_secret
374
+ scope = sdk_cfg.oidc_scope or "openid"
375
+
376
+ device_data: dict[str, str] = {
377
+ "client_id": auth_entry.client_id,
378
+ "scope": scope,
379
+ }
380
+ if client_secret:
381
+ device_data["client_secret"] = client_secret
382
+
383
+ resp = httpx.post(str(device_endpoint), data=device_data, timeout=30)
384
+ resp.raise_for_status()
385
+ device_resp = resp.json()
386
+
387
+ uri = device_resp.get("verification_uri_complete") or device_resp.get("verification_uri")
388
+ user_code = device_resp.get("user_code", "")
389
+ device_code = device_resp["device_code"]
390
+ interval = int(device_resp.get("interval", 5))
391
+ expires_in = int(device_resp.get("expires_in", 300))
392
+
393
+ print(f"\nOpen this URL in a browser:\n {uri}")
394
+ if user_code:
395
+ print(f"Enter code: {user_code}")
396
+ print(
397
+ "\nLog in when prompted, then return to this terminal. "
398
+ "The browser should show a Dex confirmation page (not the OCMO app)."
399
+ )
400
+ print("Waiting for authorization...")
401
+
402
+ deadline = time.monotonic() + expires_in
403
+ while time.monotonic() < deadline:
404
+ time.sleep(interval)
405
+ token_data: dict[str, str] = {
406
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
407
+ "device_code": device_code,
408
+ "client_id": auth_entry.client_id,
409
+ }
410
+ if client_secret:
411
+ token_data["client_secret"] = client_secret
412
+
413
+ token_resp = httpx.post(str(token_endpoint), data=token_data, timeout=30)
414
+ if token_resp.status_code == 200:
415
+ tokens = token_resp.json()
416
+ access_token = tokens.get("access_token")
417
+ if not access_token:
418
+ err("Token endpoint returned no access_token.")
419
+ raise SystemExit(1)
420
+ store_oidc_access_token(
421
+ sdk_cfg,
422
+ str(access_token),
423
+ int(tokens.get("expires_in", 3600)),
424
+ )
425
+ _remove_legacy_token_file(sdk_cfg.cache_dir)
426
+ print("Logged in successfully.")
427
+ return
428
+
429
+ body = token_resp.json()
430
+ error = body.get("error", "")
431
+ if error == "authorization_pending":
432
+ continue
433
+ if error == "slow_down":
434
+ interval += 5
435
+ continue
436
+ if error == "unsupported_grant_type":
437
+ err(
438
+ "Device flow is not enabled for this OIDC client. "
439
+ "Ask your IdP admin to allow grant type "
440
+ "'urn:ietf:params:oauth:grant-type:device_code' "
441
+ "(for local Dex, add it under oauth2.grantTypes in dex-config.yaml and restart Dex)."
442
+ )
443
+ raise SystemExit(1)
444
+ err(f"Device flow error: {body.get('error_description', error)}")
445
+ raise SystemExit(1)
446
+
447
+ err("Device authorization timed out.")
448
+ raise SystemExit(1)
449
+
450
+ except SystemExit:
451
+ raise
452
+ except Exception as exc:
453
+ err(f"Device flow error: {exc}")
454
+ raise SystemExit(1)
455
+
456
+
457
+ def _remove_legacy_token_file(cache_dir: Path) -> bool:
458
+ legacy = cache_dir / "token.json"
459
+ if legacy.exists():
460
+ legacy.unlink(missing_ok=True)
461
+ return True
462
+ return False
463
+
464
+
465
+ _STATUS_HELP = """\
466
+ Show the current authentication status and identity.
467
+
468
+ Each setting shows whether it comes from an environment variable or the config file.
469
+ Never prints secret or token values.
470
+ """
471
+
472
+
473
+ @auth_group.command("status", help=_STATUS_HELP)
474
+ @output_option("auth status")
475
+ @click.pass_obj
476
+ @sdk_command
477
+ def status_cmd(ctx: OcmoCtx, output_fmt: str | None) -> None:
478
+ report = _build_auth_status(ctx)
479
+ fmt = output_fmt or ctx.output
480
+ if fmt in ("json", "yaml"):
481
+ emit(report, fmt)
482
+ return
483
+ _print_auth_status(report)
484
+
485
+
486
+ _LOGOUT_HELP = """\
487
+ Remove cached OIDC tokens for the current configuration.
488
+
489
+ Does not revoke tokens at the OIDC provider. Environment-provided OCMO_TOKEN is not cleared.
490
+ """
491
+
492
+
493
+ @auth_group.command("logout", help=_LOGOUT_HELP)
494
+ @click.option("--context", default=None, help="Context to log out (default: current).")
495
+ @click.option("--all", "clear_all", is_flag=True, default=False, help="Clear every OIDC cache file in OCMO_CACHE_DIR.")
496
+ @click.pass_obj
497
+ @sdk_command
498
+ def logout_cmd(ctx: OcmoCtx, context: str | None, clear_all: bool) -> None:
499
+ from ocmo.auth import clear_oidc_token_cache_dir, invalidate_oidc_token_cache
500
+
501
+ cfg = load_config()
502
+ ctx_name = context or os.environ.get("OCMO_CONTEXT") or cfg.current_context
503
+ sdk_cfg = build_ocmo_config(
504
+ skip_version_check=ctx.skip_version_check,
505
+ context_name=ctx_name,
506
+ validate_auth=False,
507
+ )
508
+ removed = 0
509
+
510
+ if clear_all:
511
+ removed += clear_oidc_token_cache_dir(sdk_cfg.cache_dir)
512
+ elif sdk_cfg.auth_mode == "oidc":
513
+ if invalidate_oidc_token_cache(sdk_cfg):
514
+ removed += 1
515
+
516
+ removed += int(_remove_legacy_token_file(sdk_cfg.cache_dir) or 0)
517
+
518
+ if removed:
519
+ print(f"Logged out: removed {removed} cached token entr{'y' if removed == 1 else 'ies'}.")
520
+ else:
521
+ print("No cached OIDC tokens found for the current configuration.")
522
+
523
+
524
+ def _sourced(env_var: str, config_value: str | None) -> dict[str, Any]:
525
+ env_val = os.environ.get(env_var)
526
+ if env_val is not None and env_val != "":
527
+ return {"value": env_val, "source": "environment"}
528
+ if config_value:
529
+ return {"value": config_value, "source": "config"}
530
+ return {"value": None, "source": "unset"}
531
+
532
+
533
+ def _masked_sourced(env_var: str, config_value: str | None, *, kind: str) -> dict[str, Any]:
534
+ row = _sourced(env_var, config_value)
535
+ if row["value"]:
536
+ row["value"] = f"({kind}, set)"
537
+ return row
538
+
539
+
540
+ def _build_auth_status(ctx: OcmoCtx) -> dict[str, Any]:
541
+ cfg = load_config()
542
+ config_path = _config_path()
543
+ ctx_name = os.environ.get("OCMO_CONTEXT") or cfg.current_context or None
544
+ active: Context | None = cfg.contexts.get(ctx_name) if ctx_name else cfg.active_context()
545
+ auth_entry: AuthEntry | None = None
546
+ if active and active.auth:
547
+ auth_entry = cfg.auths.get(active.auth)
548
+
549
+ context_source = "environment" if os.environ.get("OCMO_CONTEXT") else ("config" if ctx_name else "unset")
550
+
551
+ report: dict[str, Any] = {
552
+ "config_file": {
553
+ "path": str(config_path),
554
+ "exists": config_path.exists(),
555
+ },
556
+ "context": {
557
+ "value": ctx_name,
558
+ "source": context_source,
559
+ },
560
+ "server": _sourced("OCMO_SERVER", active.server if active else None),
561
+ "namespace": _sourced("OCMO_NAMESPACE", active.namespace if active else None),
562
+ }
563
+
564
+ try:
565
+ sdk_cfg = build_ocmo_config(
566
+ skip_version_check=ctx.skip_version_check,
567
+ context_name=ctx_name,
568
+ validate_auth=False,
569
+ )
570
+ except Exception as exc:
571
+ report["auth"] = {"error": str(exc)}
572
+ return report
573
+
574
+ auth_mode_source = "environment" if os.environ.get("OCMO_AUTH_MODE") else "inferred"
575
+ report["auth_mode"] = {"value": sdk_cfg.auth_mode, "source": auth_mode_source}
576
+ report["oidc_issuer"] = _sourced(
577
+ "OCMO_OIDC_ISSUER",
578
+ auth_entry.issuer if auth_entry else None,
579
+ )
580
+ report["oidc_token_url"] = _sourced(
581
+ "OCMO_OIDC_TOKEN_URL",
582
+ None,
583
+ )
584
+ report["client_id"] = _sourced(
585
+ "OCMO_CLIENT_ID",
586
+ auth_entry.client_id if auth_entry else None,
587
+ )
588
+ report["client_secret"] = _masked_sourced(
589
+ "OCMO_CLIENT_SECRET",
590
+ auth_entry.client_secret if auth_entry else None,
591
+ kind="secret",
592
+ )
593
+ report["token"] = _masked_sourced(
594
+ "OCMO_TOKEN",
595
+ auth_entry.token if auth_entry else None,
596
+ kind="bearer" if sdk_cfg.token and not str(sdk_cfg.token).startswith("ocmort-") else "token",
597
+ )
598
+ report["oidc_grant_type"] = _sourced("OCMO_OIDC_GRANT_TYPE", None)
599
+ report["oidc_scope"] = _sourced("OCMO_OIDC_SCOPE", None)
600
+ report["cache_dir"] = {
601
+ "value": str(sdk_cfg.cache_dir),
602
+ "source": "environment" if os.environ.get("OCMO_CACHE_DIR") else "default",
603
+ }
604
+
605
+ cache_info = _oidc_cache_info(sdk_cfg)
606
+ if cache_info:
607
+ report["oidc_cache"] = cache_info
608
+
609
+ server = report["server"]["value"]
610
+ if server:
611
+ try:
612
+ whoami = ctx.client().whoami() # type: ignore[no-untyped-call]
613
+ data = as_dict(whoami)
614
+ report["identity"] = {
615
+ "auth_type": data.get("auth_type"),
616
+ "identifier": data.get("identifier"),
617
+ "display_name": data.get("display_name"),
618
+ "access_scope": data.get("access_scope"),
619
+ }
620
+ if data.get("user_details"):
621
+ report["identity"]["user_details"] = data["user_details"]
622
+ if data.get("resolver_details"):
623
+ report["identity"]["resolver_details"] = data["resolver_details"]
624
+ except Exception as exc:
625
+ report["identity"] = {"error": str(exc)}
626
+ else:
627
+ report["identity"] = {"error": "server not configured"}
628
+
629
+ return report
630
+
631
+
632
+ def _human_duration(seconds: float) -> str:
633
+ """Format a positive duration for humans (e.g. '23 minutes', '2 hours')."""
634
+ total = max(0, int(seconds))
635
+ if total < 60:
636
+ return f"{total} second{'s' if total != 1 else ''}"
637
+ minutes, secs = divmod(total, 60)
638
+ if minutes < 60:
639
+ if secs:
640
+ return f"{minutes} minute{'s' if minutes != 1 else ''} {secs}s"
641
+ return f"{minutes} minute{'s' if minutes != 1 else ''}"
642
+ hours, mins = divmod(minutes, 60)
643
+ if hours < 48:
644
+ if mins:
645
+ return f"{hours} hour{'s' if hours != 1 else ''} {mins}m"
646
+ return f"{hours} hour{'s' if hours != 1 else ''}"
647
+ days, hrs = divmod(hours, 24)
648
+ if hrs:
649
+ return f"{days} day{'s' if days != 1 else ''} {hrs}h"
650
+ return f"{days} day{'s' if days != 1 else ''}"
651
+
652
+
653
+ def _describe_token_expiry(expires_at: Any) -> dict[str, Any]:
654
+ """Turn a cache expiry timestamp into display-friendly fields."""
655
+ if expires_at is None:
656
+ return {
657
+ "status": "unknown",
658
+ "valid": False,
659
+ "expires_at": None,
660
+ "expires_in": None,
661
+ }
662
+ if not isinstance(expires_at, int | float | str):
663
+ return {
664
+ "status": "unknown",
665
+ "valid": False,
666
+ "expires_at": None,
667
+ "expires_in": None,
668
+ }
669
+ try:
670
+ ts = float(expires_at)
671
+ except (TypeError, ValueError):
672
+ return {
673
+ "status": "unknown",
674
+ "valid": False,
675
+ "expires_at": str(expires_at),
676
+ "expires_in": None,
677
+ }
678
+
679
+ remaining = ts - time.time()
680
+ local_dt = datetime.fromtimestamp(ts, tz=UTC).astimezone()
681
+ formatted = local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
682
+
683
+ if remaining > 0:
684
+ return {
685
+ "status": "valid",
686
+ "valid": True,
687
+ "expires_at": formatted,
688
+ "expires_at_unix": ts,
689
+ "expires_in": f"in {_human_duration(remaining)}",
690
+ "expires_in_seconds": int(remaining),
691
+ }
692
+ return {
693
+ "status": "expired",
694
+ "valid": False,
695
+ "expires_at": formatted,
696
+ "expires_at_unix": ts,
697
+ "expires_in": f"expired {_human_duration(-remaining)} ago",
698
+ "expires_in_seconds": int(remaining),
699
+ }
700
+
701
+
702
+ def _oidc_cache_info(sdk_cfg: OcmoConfig) -> dict[str, Any] | None:
703
+ if sdk_cfg.auth_mode != "oidc" or not sdk_cfg.client_id:
704
+ return None
705
+ try:
706
+ from ocmo.auth import oidc_cache_status
707
+
708
+ info = oidc_cache_status(sdk_cfg)
709
+ if not info.get("cached"):
710
+ return {"cached": False, "status": "none"}
711
+ expiry = _describe_token_expiry(info.get("expires_at"))
712
+ return {"cached": True, **expiry}
713
+ except Exception:
714
+ return {"cached": False, "status": "none"}
715
+
716
+
717
+ def _print_auth_status(report: dict[str, Any]) -> None:
718
+ cfg = report["config_file"]
719
+ print(f"Config file: {cfg['path']}" + ("" if cfg["exists"] else " (not created yet)"))
720
+
721
+ def _line(label: str, row: dict[str, Any]) -> None:
722
+ value = row.get("value")
723
+ source = row.get("source", "unset")
724
+ if value is None:
725
+ display = "(not set)"
726
+ else:
727
+ display = str(value)
728
+ print(f"{label:16} {display:40} [{source}]")
729
+
730
+ ctx_row = report["context"]
731
+ print(f"{'Context':16} {str(ctx_row.get('value') or '(none)'):40} [{ctx_row.get('source')}]")
732
+ _line("Server", report["server"])
733
+ _line("Namespace", report["namespace"])
734
+
735
+ auth = report.get("auth")
736
+ if auth and "error" in auth:
737
+ print(f"\nAuth config: (unavailable: {auth['error']})")
738
+ return
739
+
740
+ print()
741
+ _line("Auth mode", report["auth_mode"])
742
+ _line("OIDC issuer", report["oidc_issuer"])
743
+ if report["oidc_token_url"]["value"]:
744
+ _line("OIDC token URL", report["oidc_token_url"])
745
+ _line("Client ID", report["client_id"])
746
+ _line("Client secret", report["client_secret"])
747
+ _line("Token", report["token"])
748
+ if report["oidc_grant_type"]["value"]:
749
+ _line("OIDC grant", report["oidc_grant_type"])
750
+ if report["oidc_scope"]["value"]:
751
+ _line("OIDC scope", report["oidc_scope"])
752
+ _line("Cache dir", report["cache_dir"])
753
+
754
+ cache = report.get("oidc_cache")
755
+ if cache:
756
+ if cache.get("cached"):
757
+ expires_at = cache.get("expires_at") or "unknown time"
758
+ expires_in = cache.get("expires_in") or ""
759
+ status = cache.get("status", "valid")
760
+ if status == "expired":
761
+ print(f"{'OIDC token':16} cached, expired")
762
+ print(f"{'Expired at':16} {expires_at}")
763
+ if expires_in:
764
+ print(f"{'':16} ({expires_in})")
765
+ else:
766
+ print(f"{'OIDC token':16} cached, valid")
767
+ print(f"{'Expires at':16} {expires_at}")
768
+ if expires_in:
769
+ print(f"{'':16} ({expires_in})")
770
+ else:
771
+ print(f"{'OIDC token':16} (not cached)")
772
+
773
+ print()
774
+ identity = report.get("identity") or {}
775
+ if identity.get("error"):
776
+ print(f"Identity: ({identity['error']})")
777
+ return
778
+ ident = identity.get("identifier") or "(unknown)"
779
+ display = identity.get("display_name")
780
+ email = (identity.get("user_details") or {}).get("email")
781
+ suffix = ""
782
+ if display and display != ident:
783
+ suffix = f" ({display})"
784
+ if email:
785
+ suffix += f" <{email}>"
786
+ print(f"Identity: {ident}{suffix}")