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.
- LICENSE +201 -0
- NOTICE +16 -0
- ocmo_cli/__init__.py +3 -0
- ocmo_cli/__main__.py +4 -0
- ocmo_cli/_address.py +182 -0
- ocmo_cli/_audit_item.py +33 -0
- ocmo_cli/_auth_resolve.py +66 -0
- ocmo_cli/_body_payload.py +203 -0
- ocmo_cli/_click_groups.py +227 -0
- ocmo_cli/_client.py +351 -0
- ocmo_cli/_client_commands.py +35 -0
- ocmo_cli/_command_registry.py +29 -0
- ocmo_cli/_commands_map.py +65 -0
- ocmo_cli/_config.py +183 -0
- ocmo_cli/_config_content.py +43 -0
- ocmo_cli/_delete_item_output.py +129 -0
- ocmo_cli/_diff_input.py +144 -0
- ocmo_cli/_dry_run.py +292 -0
- ocmo_cli/_errors.py +151 -0
- ocmo_cli/_exit.py +13 -0
- ocmo_cli/_export_metadata.py +47 -0
- ocmo_cli/_generated_registry.py +45 -0
- ocmo_cli/_get_cast_output.py +52 -0
- ocmo_cli/_get_item_list.py +23 -0
- ocmo_cli/_globalpermission_output.py +98 -0
- ocmo_cli/_import_metadata.py +140 -0
- ocmo_cli/_import_plan.py +221 -0
- ocmo_cli/_item_output.py +223 -0
- ocmo_cli/_lazy_mixin.py +29 -0
- ocmo_cli/_ls_wide.py +286 -0
- ocmo_cli/_mutating.py +39 -0
- ocmo_cli/_notice.py +17 -0
- ocmo_cli/_options.py +77 -0
- ocmo_cli/_output.py +424 -0
- ocmo_cli/_output_manifest.py +332 -0
- ocmo_cli/_resolve_draft.py +50 -0
- ocmo_cli/_resolve_options.py +143 -0
- ocmo_cli/_resolve_output.py +228 -0
- ocmo_cli/_resolve_parameters_output.py +72 -0
- ocmo_cli/_resolve_series.py +89 -0
- ocmo_cli/_resolve_series_chart.py +409 -0
- ocmo_cli/_resolve_write.py +185 -0
- ocmo_cli/_resolver_output.py +34 -0
- ocmo_cli/_resource_aliases.py +22 -0
- ocmo_cli/_sdk_dispatch.py +310 -0
- ocmo_cli/_typing.py +27 -0
- ocmo_cli/_version_output.py +39 -0
- ocmo_cli/_whoami_output.py +43 -0
- ocmo_cli/commands/__init__.py +1 -0
- ocmo_cli/commands/api.py +125 -0
- ocmo_cli/commands/api_health.py +12 -0
- ocmo_cli/commands/apply.py +153 -0
- ocmo_cli/commands/auth.py +786 -0
- ocmo_cli/commands/can_i.py +51 -0
- ocmo_cli/commands/completion.py +59 -0
- ocmo_cli/commands/config_cmd.py +235 -0
- ocmo_cli/commands/describe.py +109 -0
- ocmo_cli/commands/diff.py +86 -0
- ocmo_cli/commands/edit.py +200 -0
- ocmo_cli/commands/export.py +242 -0
- ocmo_cli/commands/generated.py +907 -0
- ocmo_cli/commands/gp_move.py +108 -0
- ocmo_cli/commands/import_.py +595 -0
- ocmo_cli/commands/item_relocate.py +187 -0
- ocmo_cli/commands/ls.py +481 -0
- ocmo_cli/commands/resolve.py +658 -0
- ocmo_cli/commands/resolve_draft.py +102 -0
- ocmo_cli/commands/resolve_parameters.py +65 -0
- ocmo_cli/commands/resolve_series.py +109 -0
- ocmo_cli/commands/schema.py +112 -0
- ocmo_cli/commands/timeline.py +135 -0
- ocmo_cli/commands/version_cmd.py +102 -0
- ocmo_cli/commands/whoami.py +12 -0
- ocmo_cli/main.py +241 -0
- ocmo_cli/output_formats.yaml +295 -0
- ocmo_cli-0.8.19.dist-info/METADATA +169 -0
- ocmo_cli-0.8.19.dist-info/RECORD +81 -0
- ocmo_cli-0.8.19.dist-info/WHEEL +4 -0
- ocmo_cli-0.8.19.dist-info/entry_points.txt +2 -0
- ocmo_cli-0.8.19.dist-info/licenses/LICENSE +201 -0
- 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}")
|