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.
@@ -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,3 @@
1
+ """Valdtech Provisioning CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from valdtech_cli.cli import main
2
+
3
+
4
+ raise SystemExit(main())
@@ -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")