oed-cli 0.3.0rc2__tar.gz → 0.3.1__tar.gz
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.
- {oed_cli-0.3.0rc2/src/oed_cli.egg-info → oed_cli-0.3.1}/PKG-INFO +11 -2
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/README.md +10 -1
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/pyproject.toml +1 -1
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/__init__.py +1 -1
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/auth.py +214 -132
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/cli.py +4 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/discovery.py +5 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/http.py +7 -1
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/invoke.py +10 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/main.py +108 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1/src/oed_cli.egg-info}/PKG-INFO +11 -2
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli.egg-info/SOURCES.txt +1 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_allowlist.py +1 -1
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_auth.py +23 -9
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_cli.py +38 -15
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_invoke.py +2 -2
- oed_cli-0.3.1/tests/test_logging.py +237 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_secure_storage.py +212 -6
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_spec_integrity.py +5 -2
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/LICENSE +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/setup.cfg +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/__main__.py +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/defaults.toml +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/dynamic.py +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/errors.py +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli/py.typed +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli.egg-info/dependency_links.txt +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli.egg-info/entry_points.txt +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli.egg-info/requires.txt +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/src/oed_cli.egg-info/top_level.txt +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_ag_auth_cli.py +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_dynamic.py +0 -0
- {oed_cli-0.3.0rc2 → oed_cli-0.3.1}/tests/test_http_socks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: oed-cli
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: oed — openEuler Infra command line. Auto-discovered, AI-friendly.
|
|
5
5
|
Author: oed-cli contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -82,7 +82,7 @@ pip install -e .
|
|
|
82
82
|
Verify:
|
|
83
83
|
|
|
84
84
|
```
|
|
85
|
-
oed --version # → oed, version 0.3.
|
|
85
|
+
oed --version # → oed, version 0.3.1
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
## Quick start: install, then call
|
|
@@ -131,6 +131,15 @@ oed cve getSecurityNoticeByCveId --cve-id CVE-2019-10082 \
|
|
|
131
131
|
| jq '.response.result[0] | {cveId, affectedProduct, affectedComponent}'
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
+
By default `oed` is quiet: only the result JSON reaches stdout, and errors reach stderr — so the pipe above is always clean. Diagnostics and human-progress prompts are level-gated:
|
|
135
|
+
|
|
136
|
+
- `oed --log-level info <…>` — also show human prompts (e.g. the device-flow "visit URL" line).
|
|
137
|
+
- `oed --log-level debug <…>` — also show HTTP request/response, discovery cache, and token-refresh diagnostics.
|
|
138
|
+
- `-v` = `--log-level debug`, `-q` = `--log-level warning` (the default).
|
|
139
|
+
- `OED_LOG_LEVEL=debug|info|warning` sets it without a flag.
|
|
140
|
+
|
|
141
|
+
The flag may appear anywhere in the command line and applies to both built-in commands (`oed info`, `oed auth login`) and dynamic dispatch (`oed <service> <method>`).
|
|
142
|
+
|
|
134
143
|
```
|
|
135
144
|
{
|
|
136
145
|
"cveId": "CVE-2019-10082",
|
|
@@ -37,7 +37,7 @@ pip install -e .
|
|
|
37
37
|
Verify:
|
|
38
38
|
|
|
39
39
|
```
|
|
40
|
-
oed --version # → oed, version 0.3.
|
|
40
|
+
oed --version # → oed, version 0.3.1
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
## Quick start: install, then call
|
|
@@ -86,6 +86,15 @@ oed cve getSecurityNoticeByCveId --cve-id CVE-2019-10082 \
|
|
|
86
86
|
| jq '.response.result[0] | {cveId, affectedProduct, affectedComponent}'
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
By default `oed` is quiet: only the result JSON reaches stdout, and errors reach stderr — so the pipe above is always clean. Diagnostics and human-progress prompts are level-gated:
|
|
90
|
+
|
|
91
|
+
- `oed --log-level info <…>` — also show human prompts (e.g. the device-flow "visit URL" line).
|
|
92
|
+
- `oed --log-level debug <…>` — also show HTTP request/response, discovery cache, and token-refresh diagnostics.
|
|
93
|
+
- `-v` = `--log-level debug`, `-q` = `--log-level warning` (the default).
|
|
94
|
+
- `OED_LOG_LEVEL=debug|info|warning` sets it without a flag.
|
|
95
|
+
|
|
96
|
+
The flag may appear anywhere in the command line and applies to both built-in commands (`oed info`, `oed auth login`) and dynamic dispatch (`oed <service> <method>`).
|
|
97
|
+
|
|
89
98
|
```
|
|
90
99
|
{
|
|
91
100
|
"cveId": "CVE-2019-10082",
|
|
@@ -11,6 +11,12 @@ Storage:
|
|
|
11
11
|
cache contains ``access_token`` + ``refresh_token`` + account metadata,
|
|
12
12
|
which is what enables silent refresh on subsequent calls.
|
|
13
13
|
|
|
14
|
+
On Windows, when the OS keyring backend is unavailable (the default install
|
|
15
|
+
lacks the ``[os-keyring-windows]`` extra), the plaintext fallback is
|
|
16
|
+
transparently DPAPI-encrypted (bound to the current Windows user) before
|
|
17
|
+
being written, because NTFS ignores ``0600``. macOS / Linux write plaintext
|
|
18
|
+
JSON unchanged. See :func:`_dpapi_enabled`.
|
|
19
|
+
|
|
14
20
|
Public surface:
|
|
15
21
|
- :func:`save_auth` / :func:`load_auth` / :func:`clear_auth` — manual token
|
|
16
22
|
persistence (also synthesizes a minimal MSAL cache for backward compat)
|
|
@@ -32,12 +38,13 @@ from __future__ import annotations
|
|
|
32
38
|
|
|
33
39
|
import contextlib
|
|
34
40
|
import json
|
|
41
|
+
import logging
|
|
35
42
|
import os
|
|
36
43
|
import subprocess
|
|
37
44
|
import sys
|
|
38
45
|
import time
|
|
39
46
|
import webbrowser
|
|
40
|
-
from base64 import b64decode
|
|
47
|
+
from base64 import b64decode, b64encode
|
|
41
48
|
from collections.abc import Callable
|
|
42
49
|
from importlib.resources import files
|
|
43
50
|
from pathlib import Path
|
|
@@ -50,6 +57,8 @@ import tomllib
|
|
|
50
57
|
|
|
51
58
|
from .discovery import ag_token_path, auth_token_path
|
|
52
59
|
|
|
60
|
+
log = logging.getLogger("oed")
|
|
61
|
+
|
|
53
62
|
# Best-effort token lifetime — the openEuler gateway does not publish a TTL.
|
|
54
63
|
TOKEN_MAX_AGE = 24 * 60 * 60
|
|
55
64
|
|
|
@@ -215,8 +224,9 @@ class _SecureStore:
|
|
|
215
224
|
def save(self, payload: dict[str, Any]) -> str:
|
|
216
225
|
"""Persist ``payload`` via the best available backend.
|
|
217
226
|
|
|
218
|
-
Returns the backend label (``"keyring"
|
|
219
|
-
caller can surface it
|
|
227
|
+
Returns the backend label (``"keyring"``, ``"plaintext-dpapi"`` on
|
|
228
|
+
Windows, or ``"plaintext"`` elsewhere) so the caller can surface it
|
|
229
|
+
(``oed auth status``).
|
|
220
230
|
"""
|
|
221
231
|
|
|
222
232
|
path = self._fallback_path()
|
|
@@ -239,8 +249,8 @@ class _SecureStore:
|
|
|
239
249
|
self._available = False
|
|
240
250
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
241
251
|
_atomic_write_json_plaintext(path, payload)
|
|
242
|
-
self._last_backend = "plaintext"
|
|
243
|
-
return
|
|
252
|
+
self._last_backend = "plaintext-dpapi" if _dpapi_enabled() else "plaintext"
|
|
253
|
+
return self._last_backend
|
|
244
254
|
|
|
245
255
|
def load(self) -> dict[str, Any] | None:
|
|
246
256
|
"""Read ``payload`` from the best available backend.
|
|
@@ -269,7 +279,10 @@ class _SecureStore:
|
|
|
269
279
|
self._keyring.delete_password(_KEYRING_SERVICE, self._username) # type: ignore[union-attr]
|
|
270
280
|
# Fallback: plaintext file (migration or no-keyring envs).
|
|
271
281
|
existing = _read_existing(self._fallback_path())
|
|
272
|
-
|
|
282
|
+
if existing:
|
|
283
|
+
self._last_backend = "plaintext-dpapi" if _dpapi_enabled() else "plaintext"
|
|
284
|
+
else:
|
|
285
|
+
self._last_backend = "unprobed"
|
|
273
286
|
return existing or None
|
|
274
287
|
|
|
275
288
|
def clear(self) -> None:
|
|
@@ -283,7 +296,12 @@ class _SecureStore:
|
|
|
283
296
|
self._last_backend = "unprobed"
|
|
284
297
|
|
|
285
298
|
def backend_label(self) -> str:
|
|
286
|
-
"""Last backend actually used
|
|
299
|
+
"""Last backend actually used.
|
|
300
|
+
|
|
301
|
+
One of ``"keyring"`` (OS keystore), ``"plaintext-dpapi"`` (Windows
|
|
302
|
+
DPAPI-encrypted fallback), ``"plaintext"`` (macOS/Linux fallback), or
|
|
303
|
+
``"unprobed"``.
|
|
304
|
+
"""
|
|
287
305
|
|
|
288
306
|
return self._last_backend
|
|
289
307
|
|
|
@@ -430,6 +448,7 @@ def _try_silent_refresh(stored: dict[str, Any]) -> str | None:
|
|
|
430
448
|
with contextlib.suppress(Exception):
|
|
431
449
|
rt_entries = list(cache.search("RefreshToken"))
|
|
432
450
|
if not rt_entries:
|
|
451
|
+
log.debug("silent refresh: no refresh_token in cache, skipping")
|
|
433
452
|
return None
|
|
434
453
|
|
|
435
454
|
client_id = get_client_id()
|
|
@@ -444,8 +463,10 @@ def _try_silent_refresh(stored: dict[str, Any]) -> str | None:
|
|
|
444
463
|
|
|
445
464
|
accounts = app.get_accounts()
|
|
446
465
|
if not accounts:
|
|
466
|
+
log.debug("silent refresh: no account in cache, skipping")
|
|
447
467
|
return None
|
|
448
468
|
|
|
469
|
+
log.debug("silent refresh: attempting acquire_token_silent")
|
|
449
470
|
with contextlib.suppress(Exception):
|
|
450
471
|
result = app.acquire_token_silent(
|
|
451
472
|
scopes=[], # reserved scopes (openid/profile/offline_access) added by MSAL
|
|
@@ -454,7 +475,9 @@ def _try_silent_refresh(stored: dict[str, Any]) -> str | None:
|
|
|
454
475
|
if result and "access_token" in result:
|
|
455
476
|
# Persist the refreshed cache back to disk.
|
|
456
477
|
save_msal_auth(cache)
|
|
478
|
+
log.debug("silent refresh: succeeded, cache persisted")
|
|
457
479
|
return result["access_token"]
|
|
480
|
+
log.debug("silent refresh: no token returned")
|
|
458
481
|
return None
|
|
459
482
|
|
|
460
483
|
|
|
@@ -689,9 +712,11 @@ def device_login(
|
|
|
689
712
|
"""Run RFC 8628 device authorization grant.
|
|
690
713
|
|
|
691
714
|
Returns dict with ``access_token`` (and optional ``refresh_token``) on
|
|
692
|
-
success; ``None`` on failure or cancellation. Prints
|
|
693
|
-
|
|
694
|
-
|
|
715
|
+
success; ``None`` on failure or cancellation. Prints human prompts and
|
|
716
|
+
machine-readable stage JSON to **stderr** — stdout stays reserved for the
|
|
717
|
+
caller's single success envelope (the ``| jq`` contract). The caller is
|
|
718
|
+
responsible for persisting the resulting MSAL token cache via
|
|
719
|
+
:func:`save_msal_auth`.
|
|
695
720
|
"""
|
|
696
721
|
|
|
697
722
|
client_id = get_client_id()
|
|
@@ -747,36 +772,39 @@ def device_login(
|
|
|
747
772
|
|
|
748
773
|
user_code = flow["user_code"]
|
|
749
774
|
verification_uri = flow.get("verification_uri", "")
|
|
750
|
-
|
|
751
|
-
|
|
775
|
+
# The code-embedded URL is the one-paste path — the bare device page would
|
|
776
|
+
# make the user retype the code by hand.
|
|
777
|
+
verify_url = flow.get("verification_uri_complete") or verification_uri
|
|
752
778
|
|
|
779
|
+
# stderr human prompts first — clear, actionable, ready-to-open URL front
|
|
780
|
+
# and center. The machine-readable stage JSON stays on stderr too, so
|
|
781
|
+
# agents keep stdout reserved for the caller's single JSON envelope.
|
|
753
782
|
click.echo(
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
"stage": "device_code_issued",
|
|
758
|
-
"user_code": user_code,
|
|
759
|
-
"verification_uri": verification_uri,
|
|
760
|
-
"verification_uri_complete": flow.get("verification_uri_complete"),
|
|
761
|
-
"expires_in": expires_in,
|
|
762
|
-
"interval": interval,
|
|
763
|
-
},
|
|
764
|
-
ensure_ascii=False,
|
|
765
|
-
)
|
|
783
|
+
"1. Please open the following link in your browser and complete the "
|
|
784
|
+
f"authorization: {verification_uri}",
|
|
785
|
+
err=True,
|
|
766
786
|
)
|
|
787
|
+
if user_code:
|
|
788
|
+
click.echo(
|
|
789
|
+
f"2. Please enter the following code in the browser: "
|
|
790
|
+
f"{click.style(user_code, fg='cyan', bold=True)}",
|
|
791
|
+
err=True,
|
|
792
|
+
)
|
|
767
793
|
|
|
768
|
-
# stderr
|
|
769
|
-
|
|
794
|
+
# Human prompt (stderr, level-gated): the stage JSON above already carries
|
|
795
|
+
# user_code + verification_uri for agents; this line is the friendlier
|
|
796
|
+
# interactive nudge. Default level (warning) hides it — ``--log-level info``
|
|
797
|
+
# shows it, ``--log-level debug`` shows it plus the HTTP diagnostics.
|
|
798
|
+
log.info(
|
|
770
799
|
flow.get("message")
|
|
771
|
-
or f"Visit {verification_uri} and enter code: {user_code}"
|
|
772
|
-
err=True,
|
|
800
|
+
or f"Visit {verification_uri} and enter code: {user_code}"
|
|
773
801
|
)
|
|
774
802
|
if copy_code and _copy_to_clipboard(user_code):
|
|
775
|
-
|
|
803
|
+
log.info("(user_code copied to clipboard)")
|
|
776
804
|
|
|
777
|
-
# Best-effort auto-open the verification URL.
|
|
778
|
-
if open_browser and
|
|
779
|
-
_try_open_browser(
|
|
805
|
+
# Best-effort auto-open the verification URL (code pre-filled when possible).
|
|
806
|
+
if open_browser and verify_url:
|
|
807
|
+
_try_open_browser(verify_url)
|
|
780
808
|
|
|
781
809
|
# MSAL handles slow_down + anti-fast-poll + 5xx-keep-polling internally.
|
|
782
810
|
# Its default exit_condition uses flow["expires_at"], set in
|
|
@@ -845,18 +873,15 @@ def _fetch_user_allowlist(
|
|
|
845
873
|
The endpoint URL comes from ``defaults.toml [oauth].cli_user_data_url``
|
|
846
874
|
with an ``OED_USER_DATA_URL`` env override (useful for fork builds).
|
|
847
875
|
|
|
848
|
-
Each branch emits a
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
876
|
+
Each branch emits a human-readable diagnostic to stderr so an operator
|
|
877
|
+
running ``oed auth login`` can see what happened without grepping the
|
|
878
|
+
source. Stdout stays reserved for the success-JSON envelope — these
|
|
879
|
+
prints never break ``| jq``.
|
|
852
880
|
"""
|
|
853
881
|
url = os.environ.get("OED_USER_DATA_URL") or _get_user_data_url()
|
|
854
882
|
if not url:
|
|
855
883
|
click.echo(
|
|
856
|
-
|
|
857
|
-
{"ok": True, "stage": "user_data_skipped", "reason": "no_user_data_url"},
|
|
858
|
-
ensure_ascii=False,
|
|
859
|
-
),
|
|
884
|
+
"No user-data URL configured; skipping per-user service allowlist fetch.",
|
|
860
885
|
err=True,
|
|
861
886
|
)
|
|
862
887
|
return None
|
|
@@ -868,6 +893,7 @@ def _fetch_user_allowlist(
|
|
|
868
893
|
params: dict[str, str] = {}
|
|
869
894
|
if user_code:
|
|
870
895
|
params["user_code"] = user_code
|
|
896
|
+
log.debug("user-data fetch %s params=%s", url, params)
|
|
871
897
|
try:
|
|
872
898
|
resp = httpx.get(
|
|
873
899
|
url,
|
|
@@ -881,46 +907,29 @@ def _fetch_user_allowlist(
|
|
|
881
907
|
# an operator can tell apart auth (401/403) from missing endpoint
|
|
882
908
|
# (404) from upstream errors (5xx) without re-running the curl.
|
|
883
909
|
click.echo(
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
"stage": "user_data_fetch_failed",
|
|
888
|
-
"url": url,
|
|
889
|
-
"status": exc.response.status_code,
|
|
890
|
-
"reason": exc.response.reason_phrase,
|
|
891
|
-
"body_preview": exc.response.text[:512],
|
|
892
|
-
},
|
|
893
|
-
ensure_ascii=False,
|
|
894
|
-
),
|
|
910
|
+
f"Failed to fetch user service allowlist: HTTP {exc.response.status_code} "
|
|
911
|
+
f"{exc.response.reason_phrase} from {url}\n"
|
|
912
|
+
f" body: {exc.response.text[:512]}",
|
|
895
913
|
err=True,
|
|
896
914
|
)
|
|
897
915
|
return None
|
|
898
916
|
except httpx.HTTPError:
|
|
899
917
|
# Connect / read / DNS / timeout — no response object.
|
|
900
918
|
click.echo(
|
|
901
|
-
|
|
902
|
-
{"ok": False, "stage": "user_data_fetch_failed", "url": url},
|
|
903
|
-
ensure_ascii=False,
|
|
904
|
-
),
|
|
919
|
+
f"Failed to fetch user service allowlist (network error): {url}",
|
|
905
920
|
err=True,
|
|
906
921
|
)
|
|
907
922
|
return None
|
|
908
923
|
|
|
909
924
|
raw_body = resp.text
|
|
925
|
+
log.debug("user-data %s -> %d", url, resp.status_code)
|
|
910
926
|
try:
|
|
911
927
|
body = resp.json()
|
|
912
928
|
except ValueError:
|
|
913
929
|
click.echo(
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
"stage": "user_data_parse_failed",
|
|
918
|
-
"url": url,
|
|
919
|
-
"status": resp.status_code,
|
|
920
|
-
"body_preview": raw_body[:512],
|
|
921
|
-
},
|
|
922
|
-
ensure_ascii=False,
|
|
923
|
-
),
|
|
930
|
+
f"Failed to parse user service allowlist response (HTTP {resp.status_code}) "
|
|
931
|
+
f"from {url}\n"
|
|
932
|
+
f" body: {raw_body[:512]}",
|
|
924
933
|
err=True,
|
|
925
934
|
)
|
|
926
935
|
return None
|
|
@@ -940,35 +949,16 @@ def _fetch_user_allowlist(
|
|
|
940
949
|
data = inner # enveloped shape
|
|
941
950
|
if not isinstance(data, str):
|
|
942
951
|
click.echo(
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
"stage": "user_data_unexpected_shape",
|
|
947
|
-
"url": url,
|
|
948
|
-
"status": resp.status_code,
|
|
949
|
-
"body": body,
|
|
950
|
-
},
|
|
951
|
-
ensure_ascii=False,
|
|
952
|
-
),
|
|
952
|
+
f"Unexpected response shape from user service allowlist endpoint "
|
|
953
|
+
f"(HTTP {resp.status_code}) from {url}\n"
|
|
954
|
+
f" body: {json.dumps(body, ensure_ascii=False)[:512]}",
|
|
953
955
|
err=True,
|
|
954
956
|
)
|
|
955
957
|
return None
|
|
956
958
|
parsed = [s.strip() for s in data.split(",") if s.strip()]
|
|
957
959
|
click.echo(
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
"ok": True,
|
|
961
|
-
"stage": "user_data_fetched",
|
|
962
|
-
"url": url,
|
|
963
|
-
"status": resp.status_code,
|
|
964
|
-
"raw_data_field": data,
|
|
965
|
-
"parsed_allowlist": parsed,
|
|
966
|
-
"count": len(parsed),
|
|
967
|
-
"sent_user_code_param": bool(user_code),
|
|
968
|
-
"response_envelope": "wrapped" if isinstance(body.get("data"), dict) else "flat",
|
|
969
|
-
},
|
|
970
|
-
ensure_ascii=False,
|
|
971
|
-
),
|
|
960
|
+
f"Successfully granted {len(parsed)} services:"
|
|
961
|
+
f"({', '.join(parsed) if parsed else 'none'})",
|
|
972
962
|
err=True,
|
|
973
963
|
)
|
|
974
964
|
# Empty list is preserved (caller treats [] the same as None — fail-open).
|
|
@@ -1012,12 +1002,6 @@ def _finalize_device_result(
|
|
|
1012
1002
|
save_msal_auth(cache, allowlist=allowlist)
|
|
1013
1003
|
summary = _summarize_token_response(result)
|
|
1014
1004
|
summary["allowlist"] = allowlist
|
|
1015
|
-
click.echo(
|
|
1016
|
-
json.dumps(
|
|
1017
|
-
summary,
|
|
1018
|
-
ensure_ascii=False,
|
|
1019
|
-
)
|
|
1020
|
-
)
|
|
1021
1005
|
return result
|
|
1022
1006
|
|
|
1023
1007
|
err = result.get("error") or "unknown"
|
|
@@ -1080,13 +1064,111 @@ def manual_login() -> dict[str, Any] | None:
|
|
|
1080
1064
|
# ---------------------------------------------------------------------------
|
|
1081
1065
|
|
|
1082
1066
|
|
|
1067
|
+
def _dpapi_enabled() -> bool:
|
|
1068
|
+
"""Whether the on-disk plaintext fallback should be DPAPI-encrypted.
|
|
1069
|
+
|
|
1070
|
+
True only on Windows. macOS / Linux keep writing plaintext JSON (0600 is
|
|
1071
|
+
a real permission there, and they almost always reach the keyring backend
|
|
1072
|
+
anyway). The :func:`_dpapi_protect` / :func:`_dpapi_unprotect` ctypes calls
|
|
1073
|
+
sit behind this gate, so they are never invoked — and ``ctypes.windll`` is
|
|
1074
|
+
never touched — on non-Windows.
|
|
1075
|
+
"""
|
|
1076
|
+
|
|
1077
|
+
return sys.platform == "win32"
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _dpapi_protect(data: bytes) -> bytes:
|
|
1081
|
+
"""Encrypt ``data`` with Windows DPAPI (``crypt32.CryptProtectData``).
|
|
1082
|
+
|
|
1083
|
+
Bound to the current Windows user (no entropy), so the ciphertext can only
|
|
1084
|
+
be decrypted by this user on this machine. Raises :class:`UserError` on any
|
|
1085
|
+
failure — we deliberately do NOT fall back to plaintext, since avoiding
|
|
1086
|
+
plaintext on Windows is the whole point.
|
|
1087
|
+
"""
|
|
1088
|
+
|
|
1089
|
+
import ctypes
|
|
1090
|
+
from ctypes import wintypes
|
|
1091
|
+
|
|
1092
|
+
from .errors import UserError
|
|
1093
|
+
|
|
1094
|
+
class _DATA_BLOB(ctypes.Structure):
|
|
1095
|
+
_fields_ = [
|
|
1096
|
+
("cbData", wintypes.DWORD),
|
|
1097
|
+
("pbData", ctypes.POINTER(ctypes.c_char)),
|
|
1098
|
+
]
|
|
1099
|
+
|
|
1100
|
+
buf = ctypes.create_string_buffer(data, len(data))
|
|
1101
|
+
ptr = ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))
|
|
1102
|
+
blob_in = _DATA_BLOB(len(data), ptr) # type: ignore[arg-type]
|
|
1103
|
+
blob_out = _DATA_BLOB()
|
|
1104
|
+
try:
|
|
1105
|
+
ok = ctypes.windll.crypt32.CryptProtectData( # type: ignore[attr-defined]
|
|
1106
|
+
ctypes.byref(blob_in), None, None, None, None, 0, ctypes.byref(blob_out)
|
|
1107
|
+
)
|
|
1108
|
+
if not ok:
|
|
1109
|
+
raise OSError(f"CryptProtectData returned {ok}")
|
|
1110
|
+
size = blob_out.cbData
|
|
1111
|
+
ciphertext = ctypes.string_at(blob_out.pbData, size)
|
|
1112
|
+
except Exception as exc: # noqa: BLE001 — ctypes/OS error → readable UserError
|
|
1113
|
+
raise UserError(
|
|
1114
|
+
f"DPAPI encryption failed: {exc}",
|
|
1115
|
+
kind="dpapi_protect_failed",
|
|
1116
|
+
hint="Could not encrypt the token cache with Windows DPAPI. "
|
|
1117
|
+
"Run `oed auth login` again, or install `oed-cli[os-keyring-windows]` "
|
|
1118
|
+
"to use the Credential Manager backend instead.",
|
|
1119
|
+
) from exc
|
|
1120
|
+
finally:
|
|
1121
|
+
with contextlib.suppress(Exception):
|
|
1122
|
+
ctypes.windll.kernel32.LocalFree(blob_out.pbData) # type: ignore[attr-defined]
|
|
1123
|
+
return ciphertext
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _dpapi_unprotect(data: bytes) -> bytes:
|
|
1127
|
+
"""Decrypt DPAPI ciphertext (``crypt32.CryptUnprotectData``).
|
|
1128
|
+
|
|
1129
|
+
Raises on failure (wrong user / corrupt). The caller
|
|
1130
|
+
(:func:`_read_existing`) treats any exception as a corrupt file.
|
|
1131
|
+
"""
|
|
1132
|
+
|
|
1133
|
+
import ctypes
|
|
1134
|
+
from ctypes import wintypes
|
|
1135
|
+
|
|
1136
|
+
class _DATA_BLOB(ctypes.Structure):
|
|
1137
|
+
_fields_ = [
|
|
1138
|
+
("cbData", wintypes.DWORD),
|
|
1139
|
+
("pbData", ctypes.POINTER(ctypes.c_char)),
|
|
1140
|
+
]
|
|
1141
|
+
|
|
1142
|
+
buf = ctypes.create_string_buffer(data, len(data))
|
|
1143
|
+
ptr = ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))
|
|
1144
|
+
blob_in = _DATA_BLOB(len(data), ptr) # type: ignore[arg-type]
|
|
1145
|
+
blob_out = _DATA_BLOB()
|
|
1146
|
+
ok = ctypes.windll.crypt32.CryptUnprotectData( # type: ignore[attr-defined]
|
|
1147
|
+
ctypes.byref(blob_in), None, None, None, None, 0, ctypes.byref(blob_out)
|
|
1148
|
+
)
|
|
1149
|
+
if not ok:
|
|
1150
|
+
raise OSError(f"CryptUnprotectData returned {ok}")
|
|
1151
|
+
try:
|
|
1152
|
+
size = blob_out.cbData
|
|
1153
|
+
return ctypes.string_at(blob_out.pbData, size)
|
|
1154
|
+
finally:
|
|
1155
|
+
ctypes.windll.kernel32.LocalFree(blob_out.pbData) # type: ignore[attr-defined]
|
|
1156
|
+
|
|
1157
|
+
|
|
1083
1158
|
def _read_existing(path: Path) -> dict[str, Any]:
|
|
1084
|
-
"""Read existing ``auth.json``; return empty dict if missing or corrupt.
|
|
1159
|
+
"""Read existing ``auth.json``; return empty dict if missing or corrupt.
|
|
1160
|
+
|
|
1161
|
+
On Windows an encrypted envelope ``{"dpapi_v1": "<base64 ciphertext>"}``
|
|
1162
|
+
is transparently decrypted. A legacy plaintext file (no ``dpapi_v1`` key)
|
|
1163
|
+
is returned as-is — it migrates to the encrypted form on the next
|
|
1164
|
+
:func:`_atomic_write_json_plaintext` write. Decrypt failure (wrong user /
|
|
1165
|
+
corrupt) is treated like any corrupt file: unlink + return ``{}``.
|
|
1166
|
+
"""
|
|
1085
1167
|
|
|
1086
1168
|
if not path.is_file():
|
|
1087
1169
|
return {}
|
|
1088
1170
|
try:
|
|
1089
|
-
|
|
1171
|
+
parsed = json.loads(path.read_text(encoding="utf-8"))
|
|
1090
1172
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
1091
1173
|
# Corrupt file — wrong encoding / truncated / half-written. Drop it
|
|
1092
1174
|
# rather than crash the login flow. Caller treats {} as
|
|
@@ -1095,6 +1177,17 @@ def _read_existing(path: Path) -> dict[str, Any]:
|
|
|
1095
1177
|
path.unlink()
|
|
1096
1178
|
return {}
|
|
1097
1179
|
|
|
1180
|
+
if _dpapi_enabled() and isinstance(parsed, dict) and "dpapi_v1" in parsed:
|
|
1181
|
+
try:
|
|
1182
|
+
ciphertext = b64decode(parsed["dpapi_v1"])
|
|
1183
|
+
plaintext = _dpapi_unprotect(ciphertext)
|
|
1184
|
+
return json.loads(plaintext.decode("utf-8"))
|
|
1185
|
+
except Exception: # noqa: BLE001 — wrong user / corrupt ciphertext
|
|
1186
|
+
with contextlib.suppress(OSError):
|
|
1187
|
+
path.unlink()
|
|
1188
|
+
return {}
|
|
1189
|
+
return parsed
|
|
1190
|
+
|
|
1098
1191
|
|
|
1099
1192
|
def _atomic_write_json_plaintext(path: Path, data: dict[str, Any]) -> None:
|
|
1100
1193
|
"""Write ``data`` as JSON to ``path`` atomically with 0600 perms.
|
|
@@ -1103,10 +1196,24 @@ def _atomic_write_json_plaintext(path: Path, data: dict[str, Any]) -> None:
|
|
|
1103
1196
|
is unreachable. Production callers go through
|
|
1104
1197
|
:func:`save_auth` / :func:`save_msal_auth` / :func:`update_auth_from_response_headers`,
|
|
1105
1198
|
not this helper directly.
|
|
1199
|
+
|
|
1200
|
+
On Windows the plaintext payload is DPAPI-encrypted (bound to the current
|
|
1201
|
+
Windows user) before being written, so a default install without the
|
|
1202
|
+
``[os-keyring-windows]`` extra still never lands an unencrypted token on
|
|
1203
|
+
disk — NTFS ignores ``0600``, so the app-layer encryption is the real
|
|
1204
|
+
protection. macOS / Linux write plaintext JSON unchanged (0600 is real
|
|
1205
|
+
there). See :func:`_dpapi_enabled`.
|
|
1106
1206
|
"""
|
|
1107
1207
|
|
|
1108
1208
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
1109
|
-
|
|
1209
|
+
if _dpapi_enabled():
|
|
1210
|
+
blob = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
|
1211
|
+
ciphertext = _dpapi_protect(blob)
|
|
1212
|
+
payload = {"dpapi_v1": b64encode(ciphertext).decode("ascii")}
|
|
1213
|
+
body = json.dumps(payload, indent=2)
|
|
1214
|
+
else:
|
|
1215
|
+
body = json.dumps(data, ensure_ascii=False, indent=2)
|
|
1216
|
+
tmp.write_text(body, encoding="utf-8")
|
|
1110
1217
|
with contextlib.suppress(OSError):
|
|
1111
1218
|
os.chmod(tmp, 0o600)
|
|
1112
1219
|
tmp.replace(path)
|
|
@@ -1383,7 +1490,6 @@ def auth_logout_cmd() -> None:
|
|
|
1383
1490
|
)
|
|
1384
1491
|
)
|
|
1385
1492
|
|
|
1386
|
-
|
|
1387
1493
|
def run_login(manual: bool) -> None:
|
|
1388
1494
|
"""Public entry for the top-level ``oed login`` shortcut.
|
|
1389
1495
|
|
|
@@ -1403,7 +1509,6 @@ def _run_login(manual: bool) -> None:
|
|
|
1403
1509
|
"""
|
|
1404
1510
|
|
|
1405
1511
|
from .errors import UserError
|
|
1406
|
-
|
|
1407
1512
|
if manual:
|
|
1408
1513
|
try:
|
|
1409
1514
|
creds = manual_login()
|
|
@@ -1417,9 +1522,6 @@ def _run_login(manual: bool) -> None:
|
|
|
1417
1522
|
)
|
|
1418
1523
|
raise SystemExit(1)
|
|
1419
1524
|
token = creds.get("token")
|
|
1420
|
-
cookie = creds.get("cookie")
|
|
1421
|
-
refresh_token = None
|
|
1422
|
-
id_token = None
|
|
1423
1525
|
else:
|
|
1424
1526
|
# device_login() reads get_scopes() internally; pass nothing and let
|
|
1425
1527
|
# it fall back to _BASE_SCOPES. Passing scope=[] would override the
|
|
@@ -1437,9 +1539,6 @@ def _run_login(manual: bool) -> None:
|
|
|
1437
1539
|
)
|
|
1438
1540
|
raise SystemExit(1)
|
|
1439
1541
|
token = result.get("access_token")
|
|
1440
|
-
cookie = None
|
|
1441
|
-
refresh_token = result.get("refresh_token")
|
|
1442
|
-
id_token = result.get("id_token")
|
|
1443
1542
|
|
|
1444
1543
|
if not token:
|
|
1445
1544
|
click.echo(
|
|
@@ -1448,25 +1547,7 @@ def _run_login(manual: bool) -> None:
|
|
|
1448
1547
|
)
|
|
1449
1548
|
raise SystemExit(1)
|
|
1450
1549
|
|
|
1451
|
-
|
|
1452
|
-
path = auth_token_path()
|
|
1453
|
-
|
|
1454
|
-
click.echo(
|
|
1455
|
-
json.dumps(
|
|
1456
|
-
{
|
|
1457
|
-
"ok": True,
|
|
1458
|
-
"stage": "saved",
|
|
1459
|
-
"path": str(path),
|
|
1460
|
-
"has_cookie": bool(cookie),
|
|
1461
|
-
"has_refresh_token": bool(refresh_token),
|
|
1462
|
-
"has_id_token": bool(id_token),
|
|
1463
|
-
"token_fingerprint": _token_fingerprint(token),
|
|
1464
|
-
},
|
|
1465
|
-
ensure_ascii=False,
|
|
1466
|
-
indent=2,
|
|
1467
|
-
)
|
|
1468
|
-
)
|
|
1469
|
-
|
|
1550
|
+
click.echo(click.style("Login succeeded.", fg="green", bold=True))
|
|
1470
1551
|
|
|
1471
1552
|
@auth_group.command("login")
|
|
1472
1553
|
@click.option(
|
|
@@ -1593,10 +1674,11 @@ def token_backend(service: str = "ag") -> str:
|
|
|
1593
1674
|
"""The storage backend actually used for ``service``'s last write/read.
|
|
1594
1675
|
|
|
1595
1676
|
Reflects the cached verdict from the most recent :meth:`_SecureStore.save`
|
|
1596
|
-
/ :meth:`_SecureStore.load` (``"keyring"`` / ``"plaintext"`` /
|
|
1597
|
-
without re-probing. Use it right after
|
|
1598
|
-
*where* their token landed — the OS
|
|
1599
|
-
|
|
1677
|
+
/ :meth:`_SecureStore.load` (``"keyring"`` / ``"plaintext-dpapi"`` /
|
|
1678
|
+
``"plaintext"`` / ``"unprobed"``) without re-probing. Use it right after
|
|
1679
|
+
:func:`store_token` to tell the user *where* their token landed — the OS
|
|
1680
|
+
credential manager (no on-disk file), the DPAPI-encrypted fallback
|
|
1681
|
+
(Windows), or the 0600 plaintext fallback (the file at :func:`token_path`).
|
|
1600
1682
|
"""
|
|
1601
1683
|
|
|
1602
1684
|
return _ag_store(service).backend_label()
|
|
@@ -1605,9 +1687,9 @@ def token_backend(service: str = "ag") -> str:
|
|
|
1605
1687
|
def token_info(service: str = "ag") -> dict[str, Any] | None:
|
|
1606
1688
|
"""Return ``{encryption, created_at}`` metadata for ``service``'s token.
|
|
1607
1689
|
|
|
1608
|
-
``encryption`` reports the backend actually used (``"keyring"
|
|
1609
|
-
``"plaintext"``
|
|
1610
|
-
no token is stored.
|
|
1690
|
+
``encryption`` reports the backend actually used (``"keyring"``,
|
|
1691
|
+
``"plaintext-dpapi"`` on Windows, or ``"plaintext"`` on macOS/Linux), not
|
|
1692
|
+
a hand-rolled cipher marker. Returns ``None`` when no token is stored.
|
|
1611
1693
|
"""
|
|
1612
1694
|
|
|
1613
1695
|
store = _ag_store(service)
|
|
@@ -137,6 +137,10 @@ def cli(ctx: click.Context, no_color: bool) -> None:
|
|
|
137
137
|
"""oed — openEuler Infra command line. Auto-discovered, AI-friendly."""
|
|
138
138
|
ctx.ensure_object(dict)
|
|
139
139
|
ctx.obj["no_color"] = no_color
|
|
140
|
+
# Bare `oed` (no subcommand) renders the same help as `oed --help`
|
|
141
|
+
# (including the auto-discovered services section from OedCli.get_help).
|
|
142
|
+
if ctx.invoked_subcommand is None:
|
|
143
|
+
click.echo(ctx.get_help())
|
|
140
144
|
|
|
141
145
|
|
|
142
146
|
# Register reserved sub-groups from sibling modules.
|