valdtech-cli 0.1.0__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.
- valdtech_cli-0.1.0/PKG-INFO +10 -0
- valdtech_cli-0.1.0/build.py +30 -0
- valdtech_cli-0.1.0/pyproject.toml +32 -0
- valdtech_cli-0.1.0/src/valdtech_cli/__init__.py +3 -0
- valdtech_cli-0.1.0/src/valdtech_cli/__main__.py +4 -0
- valdtech_cli-0.1.0/src/valdtech_cli/auth.py +201 -0
- valdtech_cli-0.1.0/src/valdtech_cli/cli.py +738 -0
- valdtech_cli-0.1.0/src/valdtech_cli/config.py +196 -0
- valdtech_cli-0.1.0/src/valdtech_cli/errors.py +49 -0
- valdtech_cli-0.1.0/src/valdtech_cli/http.py +145 -0
- valdtech_cli-0.1.0/src/valdtech_cli/output.py +76 -0
- valdtech_cli-0.1.0/valdtech_build_backend.py +162 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: valdtech-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Thin, automation-safe client for the Valdtech Provisioning API
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Provides-Extra: workload
|
|
7
|
+
Requires-Dist: cryptography==46.0.7; extra == "workload"
|
|
8
|
+
Description-Content-Type: text/plain
|
|
9
|
+
|
|
10
|
+
Thin, automation-safe client for the Valdtech Provisioning API.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Build local universal Valdtech CLI artifacts without network access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import valdtech_build_backend as backend
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> int:
|
|
12
|
+
parser = argparse.ArgumentParser()
|
|
13
|
+
parser.add_argument("--output-dir", required=True)
|
|
14
|
+
parser.add_argument("--wheel", action=argparse.BooleanOptionalAction, default=True)
|
|
15
|
+
parser.add_argument("--sdist", action=argparse.BooleanOptionalAction, default=True)
|
|
16
|
+
parser.add_argument("--pyz", action=argparse.BooleanOptionalAction, default=True)
|
|
17
|
+
args = parser.parse_args()
|
|
18
|
+
output = Path(args.output_dir)
|
|
19
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
if args.wheel:
|
|
21
|
+
print(output / backend.build_wheel(output))
|
|
22
|
+
if args.sdist:
|
|
23
|
+
print(output / backend.build_sdist(output))
|
|
24
|
+
if args.pyz:
|
|
25
|
+
print(output / backend.build_zipapp(output / "valdtechctl.pyz"))
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = []
|
|
3
|
+
build-backend = "valdtech_build_backend"
|
|
4
|
+
backend-path = ["."]
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "valdtech-cli"
|
|
8
|
+
dynamic = ["version"]
|
|
9
|
+
description = "Thin, automation-safe client for the Valdtech Provisioning API"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = []
|
|
12
|
+
authors = [{ name = "Valdtech" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
workload = ["cryptography==46.0.7"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
valdtechctl = "valdtech_cli.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Documentation = "https://github.com/ValdtechSSO/valdtechsecurity"
|
|
32
|
+
Source = "https://github.com/ValdtechSSO/valdtechsecurity"
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
import webbrowser
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from .config import AccessToken, ConfigStore, Context
|
|
14
|
+
from .errors import ApiError, CliError, ExitCode
|
|
15
|
+
from .http import HttpClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class LoginResult:
|
|
20
|
+
token: AccessToken
|
|
21
|
+
granted_scope: str | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def workload_login(
|
|
25
|
+
context: Context,
|
|
26
|
+
store: ConfigStore,
|
|
27
|
+
timeout: float,
|
|
28
|
+
private_key_file: str,
|
|
29
|
+
key_id: str | None,
|
|
30
|
+
password: str | None,
|
|
31
|
+
) -> LoginResult:
|
|
32
|
+
try:
|
|
33
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
34
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
35
|
+
except ImportError as error:
|
|
36
|
+
raise CliError(
|
|
37
|
+
"Workload login requires the optional 'workload' dependency.",
|
|
38
|
+
ExitCode.AUTHENTICATION,
|
|
39
|
+
"workload-dependency-missing",
|
|
40
|
+
) from error
|
|
41
|
+
|
|
42
|
+
issuer_client = HttpClient(context.issuer, timeout)
|
|
43
|
+
discovery = _object(issuer_client.get(
|
|
44
|
+
f"{context.issuer}/.well-known/openid-configuration",
|
|
45
|
+
authenticated=False,
|
|
46
|
+
).json(), "discovery")
|
|
47
|
+
token_endpoint = _https_endpoint(discovery.get("token_endpoint"), "token_endpoint")
|
|
48
|
+
try:
|
|
49
|
+
key_bytes = Path(private_key_file).read_bytes()
|
|
50
|
+
private_key = serialization.load_pem_private_key(
|
|
51
|
+
key_bytes,
|
|
52
|
+
password=password.encode("utf-8") if password else None,
|
|
53
|
+
)
|
|
54
|
+
except (OSError, ValueError, TypeError) as error:
|
|
55
|
+
raise CliError(
|
|
56
|
+
"The workload private key could not be loaded.",
|
|
57
|
+
ExitCode.AUTHENTICATION,
|
|
58
|
+
"workload-private-key-invalid",
|
|
59
|
+
) from error
|
|
60
|
+
|
|
61
|
+
now = int(time.time())
|
|
62
|
+
header: dict[str, str] = {"alg": "RS256", "typ": "JWT"}
|
|
63
|
+
if key_id:
|
|
64
|
+
header["kid"] = key_id
|
|
65
|
+
claims = {
|
|
66
|
+
"iss": context.client_id,
|
|
67
|
+
"sub": context.client_id,
|
|
68
|
+
"aud": token_endpoint,
|
|
69
|
+
"iat": now,
|
|
70
|
+
"exp": now + 300,
|
|
71
|
+
"jti": str(uuid.uuid4()),
|
|
72
|
+
}
|
|
73
|
+
signing_input = f"{_b64json(header)}.{_b64json(claims)}".encode("ascii")
|
|
74
|
+
try:
|
|
75
|
+
signature = private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
|
|
76
|
+
except (AttributeError, TypeError, ValueError) as error:
|
|
77
|
+
raise CliError(
|
|
78
|
+
"The workload key is not an RSA signing key.",
|
|
79
|
+
ExitCode.AUTHENTICATION,
|
|
80
|
+
"workload-signing-key-invalid",
|
|
81
|
+
) from error
|
|
82
|
+
assertion = f"{signing_input.decode('ascii')}.{_b64(signature)}"
|
|
83
|
+
token_document = _object(issuer_client.post_form(
|
|
84
|
+
token_endpoint,
|
|
85
|
+
{
|
|
86
|
+
"grant_type": "client_credentials",
|
|
87
|
+
"client_id": context.client_id,
|
|
88
|
+
"scope": " ".join(context.scopes),
|
|
89
|
+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
90
|
+
"client_assertion": assertion,
|
|
91
|
+
},
|
|
92
|
+
sensitive_values=(assertion,),
|
|
93
|
+
).json(), "token response")
|
|
94
|
+
access_token = _required(token_document, "access_token")
|
|
95
|
+
expires_in = _positive_int(token_document.get("expires_in"), "expires_in")
|
|
96
|
+
stored = store.save_token(context.name, access_token, expires_in)
|
|
97
|
+
scope = token_document.get("scope") if isinstance(token_document.get("scope"), str) else None
|
|
98
|
+
return LoginResult(stored, scope)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def device_login(
|
|
102
|
+
context: Context,
|
|
103
|
+
store: ConfigStore,
|
|
104
|
+
timeout: float,
|
|
105
|
+
*,
|
|
106
|
+
open_browser: bool = False,
|
|
107
|
+
sleeper: Callable[[float], None] = time.sleep,
|
|
108
|
+
) -> LoginResult:
|
|
109
|
+
issuer_client = HttpClient(context.issuer, timeout)
|
|
110
|
+
discovery_url = f"{context.issuer}/.well-known/openid-configuration"
|
|
111
|
+
discovery = _object(issuer_client.get(discovery_url, authenticated=False).json(), "discovery")
|
|
112
|
+
device_endpoint = _https_endpoint(discovery.get("device_authorization_endpoint"), "device_authorization_endpoint")
|
|
113
|
+
token_endpoint = _https_endpoint(discovery.get("token_endpoint"), "token_endpoint")
|
|
114
|
+
response = _object(
|
|
115
|
+
issuer_client.post_form(
|
|
116
|
+
device_endpoint,
|
|
117
|
+
{"client_id": context.client_id, "scope": " ".join(context.scopes)},
|
|
118
|
+
).json(),
|
|
119
|
+
"device authorization response",
|
|
120
|
+
)
|
|
121
|
+
device_code = _required(response, "device_code")
|
|
122
|
+
user_code = _required(response, "user_code")
|
|
123
|
+
verification_uri = _required(response, "verification_uri")
|
|
124
|
+
verification_complete = response.get("verification_uri_complete")
|
|
125
|
+
expires_in = _positive_int(response.get("expires_in"), "expires_in")
|
|
126
|
+
interval = max(_positive_int(response.get("interval", 5), "interval"), 1)
|
|
127
|
+
|
|
128
|
+
print(f"Open {verification_uri} and enter code {user_code}.", file=sys.stderr)
|
|
129
|
+
if open_browser:
|
|
130
|
+
webbrowser.open(str(verification_complete or verification_uri), new=2)
|
|
131
|
+
|
|
132
|
+
deadline = time.monotonic() + min(expires_in, max(int(timeout), 1) * 20)
|
|
133
|
+
while time.monotonic() < deadline:
|
|
134
|
+
sleeper(interval)
|
|
135
|
+
try:
|
|
136
|
+
token_response = issuer_client.post_form(
|
|
137
|
+
token_endpoint,
|
|
138
|
+
{
|
|
139
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
140
|
+
"device_code": device_code,
|
|
141
|
+
"client_id": context.client_id,
|
|
142
|
+
},
|
|
143
|
+
sensitive_values=(device_code,),
|
|
144
|
+
)
|
|
145
|
+
token_document = _object(token_response.json(), "token response")
|
|
146
|
+
except ApiError as error:
|
|
147
|
+
if error.status != 400 or not isinstance(error.details, dict):
|
|
148
|
+
raise
|
|
149
|
+
token_document = error.details
|
|
150
|
+
error = token_document.get("error")
|
|
151
|
+
if error == "authorization_pending":
|
|
152
|
+
continue
|
|
153
|
+
if error == "slow_down":
|
|
154
|
+
interval += 5
|
|
155
|
+
continue
|
|
156
|
+
if error:
|
|
157
|
+
raise CliError("Device authorization was not completed.", ExitCode.AUTHENTICATION, str(error))
|
|
158
|
+
access_token = _required(token_document, "access_token")
|
|
159
|
+
token_expires = _positive_int(token_document.get("expires_in"), "expires_in")
|
|
160
|
+
stored = store.save_token(context.name, access_token, token_expires)
|
|
161
|
+
scope = token_document.get("scope") if isinstance(token_document.get("scope"), str) else None
|
|
162
|
+
return LoginResult(stored, scope)
|
|
163
|
+
raise CliError("Device authorization expired before completion.", ExitCode.AUTHENTICATION, "device-authorization-expired")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _object(value: Any, name: str) -> dict[str, Any]:
|
|
167
|
+
if not isinstance(value, dict):
|
|
168
|
+
raise CliError(f"The {name} is invalid.", ExitCode.AUTHENTICATION, "invalid-auth-response")
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _required(value: dict[str, Any], key: str) -> str:
|
|
173
|
+
item = value.get(key)
|
|
174
|
+
if not isinstance(item, str) or not item:
|
|
175
|
+
raise CliError("The authorization server response is incomplete.", ExitCode.AUTHENTICATION, "invalid-auth-response")
|
|
176
|
+
return item
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _positive_int(value: Any, key: str) -> int:
|
|
180
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
181
|
+
raise CliError(f"The authorization server returned an invalid {key}.", ExitCode.AUTHENTICATION, "invalid-auth-response")
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _https_endpoint(value: Any, key: str) -> str:
|
|
186
|
+
from urllib.parse import urlsplit
|
|
187
|
+
|
|
188
|
+
endpoint = value if isinstance(value, str) else ""
|
|
189
|
+
parsed = urlsplit(endpoint)
|
|
190
|
+
local_http = parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
191
|
+
if not endpoint or (parsed.scheme != "https" and not local_http) or parsed.username or parsed.password:
|
|
192
|
+
raise CliError(f"Discovery returned an unsafe {key}.", ExitCode.AUTHENTICATION, "unsafe-auth-endpoint")
|
|
193
|
+
return endpoint
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _b64json(value: dict[str, Any]) -> str:
|
|
197
|
+
return _b64(json.dumps(value, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _b64(value: bytes) -> str:
|
|
201
|
+
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|