kx-auth-cli 0.5.0__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.
- kx_auth_cli/__init__.py +11 -0
- kx_auth_cli/assert_cmd.py +278 -0
- kx_auth_cli/atomic.py +54 -0
- kx_auth_cli/cache.py +66 -0
- kx_auth_cli/cli.py +103 -0
- kx_auth_cli/discovery.py +261 -0
- kx_auth_cli/envelope.py +145 -0
- kx_auth_cli/exchange.py +212 -0
- kx_auth_cli/introspect.py +162 -0
- kx_auth_cli/login.py +153 -0
- kx_auth_cli/qbridge.py +54 -0
- kx_auth_cli/rbac.py +759 -0
- kx_auth_cli-0.5.0.dist-info/METADATA +116 -0
- kx_auth_cli-0.5.0.dist-info/RECORD +16 -0
- kx_auth_cli-0.5.0.dist-info/WHEEL +4 -0
- kx_auth_cli-0.5.0.dist-info/entry_points.txt +2 -0
kx_auth_cli/discovery.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""OAuth discovery + device-code primitives for ``kx auth login`` (stdlib + httpx, fastmcp-free).
|
|
2
|
+
|
|
3
|
+
The mediation model is fixed by the MCP spec: the caller points at an endpoint it already knows —
|
|
4
|
+
**the MCP server (the resource server)** — and that endpoint advertises its authorization server, so
|
|
5
|
+
no realm, client-id or AS URL is ever hand-configured. The flow is:
|
|
6
|
+
|
|
7
|
+
1. **RFC 9728** — GET the protected-resource metadata → ``authorization_servers``. The path-aware
|
|
8
|
+
form the RFC specifies is tried first (``/.well-known/oauth-protected-resource/<path>`` for a
|
|
9
|
+
server mounted under a path), falling back to the origin-root form.
|
|
10
|
+
2. **RFC 8414 / OIDC** — GET the AS's ``.well-known`` metadata → device + token (+ registration)
|
|
11
|
+
endpoints.
|
|
12
|
+
3. **RFC 7591 (optional)** — Dynamic Client Registration when the AS advertises a
|
|
13
|
+
``registration_endpoint`` and no ``--client-id`` was given (the spec's "user never hand-configures
|
|
14
|
+
a client-id" ideal).
|
|
15
|
+
4. **RFC 8628** — device-authorization request, then poll the token endpoint.
|
|
16
|
+
|
|
17
|
+
After discovery the client talks **directly to the authorization server** — never back through the
|
|
18
|
+
resource server, which the spec forbids from passing the token through. Errors carry a ``reason``
|
|
19
|
+
code so the command layer can map them to the stable exit-code contract.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any, Callable, Optional
|
|
27
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DiscoveryError(Exception):
|
|
33
|
+
"""RFC 9728/8414 discovery failed (no metadata, no AS, no required endpoint)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DeviceAuthError(Exception):
|
|
37
|
+
"""The device-code flow failed. ``reason`` is the OAuth error code (or a synthetic one).
|
|
38
|
+
|
|
39
|
+
Reasons the command layer maps to exit codes: ``access_denied`` → 4 (denied);
|
|
40
|
+
``expired_token`` / ``timeout`` → 3 (auth-required); everything else → 1 (error).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, *, reason: str = "error") -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.reason = reason
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class AuthMetadata:
|
|
50
|
+
"""The authorization-server endpoints `login` needs, resolved from discovery."""
|
|
51
|
+
|
|
52
|
+
authorization_server: str
|
|
53
|
+
token_endpoint: str
|
|
54
|
+
device_authorization_endpoint: Optional[str] = None
|
|
55
|
+
registration_endpoint: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _checked_url(value: Any, what: str, *, optional: bool = False) -> Optional[str]:
|
|
59
|
+
"""Reject a URL httpx cannot parse as a DiscoveryError rather than a raw exception.
|
|
60
|
+
|
|
61
|
+
Most URLs here arrive from a *remote* metadata document, so a broken or hostile server must not be
|
|
62
|
+
able to crash the CLI. ``httpx.InvalidURL`` does not subclass ``httpx.HTTPError`` and
|
|
63
|
+
``urlsplit``/``urljoin`` raise a bare ``ValueError``, so without this a malformed URL escapes the
|
|
64
|
+
module uncaught — a traceback instead of the command layer's exit-code contract. The type check is
|
|
65
|
+
part of that: remote JSON can hold anything, and a non-string would reach ``.rstrip``/``httpx``
|
|
66
|
+
as an ``AttributeError``. ``optional`` permits an absent endpoint, which is not a malformed one.
|
|
67
|
+
"""
|
|
68
|
+
if value is None and optional:
|
|
69
|
+
return None
|
|
70
|
+
if not isinstance(value, str):
|
|
71
|
+
raise DiscoveryError(f"{what} was not a URL string: {value!r}")
|
|
72
|
+
try:
|
|
73
|
+
httpx.URL(value)
|
|
74
|
+
except (httpx.InvalidURL, ValueError) as exc:
|
|
75
|
+
raise DiscoveryError(f"{what} is not a usable URL ({value!r}): {exc}") from exc
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _prm_candidates(server: str) -> list[str]:
|
|
80
|
+
"""RFC 9728 well-known URLs for a resource: the path-aware form first, then the origin root."""
|
|
81
|
+
try:
|
|
82
|
+
parts = urlsplit(server)
|
|
83
|
+
except ValueError as exc: # e.g. a malformed IPv6-shaped host — stdlib raises, httpx never sees it
|
|
84
|
+
raise DiscoveryError(f"--server is not a usable URL ({server!r}): {exc}") from exc
|
|
85
|
+
origin = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
|
86
|
+
root = origin + "/.well-known/oauth-protected-resource"
|
|
87
|
+
path = parts.path.strip("/")
|
|
88
|
+
return [f"{root}/{path}", root] if path else [root]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _get_json(client: httpx.Client, url: str) -> dict[str, Any]:
|
|
92
|
+
resp = client.get(url, headers={"Accept": "application/json"})
|
|
93
|
+
resp.raise_for_status()
|
|
94
|
+
try:
|
|
95
|
+
body = resp.json()
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
raise DiscoveryError(f"metadata at {url} was not valid JSON") from exc
|
|
98
|
+
if not isinstance(body, dict):
|
|
99
|
+
raise DiscoveryError(f"metadata at {url} was not a JSON object")
|
|
100
|
+
return body
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def discover(server: str, *, client: httpx.Client) -> AuthMetadata:
|
|
104
|
+
"""Resolve the AS endpoints for an MCP-server (resource) URL via RFC 9728 → RFC 8414/OIDC."""
|
|
105
|
+
prm: Optional[dict[str, Any]] = None
|
|
106
|
+
last_prm_exc: Optional[Exception] = None
|
|
107
|
+
for prm_url in _prm_candidates(server):
|
|
108
|
+
try:
|
|
109
|
+
candidate = _get_json(client, prm_url)
|
|
110
|
+
except (httpx.HTTPError, httpx.InvalidURL, DiscoveryError) as exc:
|
|
111
|
+
last_prm_exc = exc
|
|
112
|
+
continue
|
|
113
|
+
if candidate.get("authorization_servers"):
|
|
114
|
+
prm = candidate
|
|
115
|
+
break
|
|
116
|
+
last_prm_exc = DiscoveryError(
|
|
117
|
+
f"{prm_url} advertised no authorization_servers — cannot discover an IdP"
|
|
118
|
+
)
|
|
119
|
+
if prm is None:
|
|
120
|
+
raise DiscoveryError(
|
|
121
|
+
f"could not discover protected-resource metadata for {server}: {last_prm_exc}"
|
|
122
|
+
)
|
|
123
|
+
as_url = _checked_url(prm["authorization_servers"][0], f"{server}'s advertised authorization server")
|
|
124
|
+
|
|
125
|
+
# RFC 8414 and OIDC differ in well-known path; try both against the AS origin + issuer path.
|
|
126
|
+
candidates = [
|
|
127
|
+
urljoin(as_url.rstrip("/") + "/", ".well-known/oauth-authorization-server"),
|
|
128
|
+
urljoin(as_url.rstrip("/") + "/", ".well-known/openid-configuration"),
|
|
129
|
+
]
|
|
130
|
+
last_exc: Optional[Exception] = None
|
|
131
|
+
for url in candidates:
|
|
132
|
+
try:
|
|
133
|
+
meta = _get_json(client, url)
|
|
134
|
+
except (httpx.HTTPError, httpx.InvalidURL, DiscoveryError) as exc:
|
|
135
|
+
last_exc = exc
|
|
136
|
+
continue
|
|
137
|
+
token_endpoint = meta.get("token_endpoint")
|
|
138
|
+
if not token_endpoint:
|
|
139
|
+
last_exc = DiscoveryError(f"{url} advertised no token_endpoint")
|
|
140
|
+
continue
|
|
141
|
+
return AuthMetadata(
|
|
142
|
+
authorization_server=as_url,
|
|
143
|
+
token_endpoint=_checked_url(token_endpoint, f"{url}'s token_endpoint"),
|
|
144
|
+
device_authorization_endpoint=_checked_url(
|
|
145
|
+
meta.get("device_authorization_endpoint"),
|
|
146
|
+
f"{url}'s device_authorization_endpoint",
|
|
147
|
+
optional=True,
|
|
148
|
+
),
|
|
149
|
+
registration_endpoint=_checked_url(
|
|
150
|
+
meta.get("registration_endpoint"), f"{url}'s registration_endpoint", optional=True
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
raise DiscoveryError(
|
|
154
|
+
f"could not fetch authorization-server metadata from {as_url}: {last_exc}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def register_client(
|
|
159
|
+
meta: AuthMetadata, *, client: httpx.Client, client_name: str, scopes: Optional[list[str]] = None
|
|
160
|
+
) -> str:
|
|
161
|
+
"""RFC 7591 Dynamic Client Registration for the device-code flow → the issued ``client_id``."""
|
|
162
|
+
if not meta.registration_endpoint:
|
|
163
|
+
raise DiscoveryError(
|
|
164
|
+
"the authorization server advertises no registration_endpoint — pass --client-id "
|
|
165
|
+
"(or $KX_AUTH_CLIENT_ID) instead"
|
|
166
|
+
)
|
|
167
|
+
payload: dict[str, Any] = {
|
|
168
|
+
"client_name": client_name,
|
|
169
|
+
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code"],
|
|
170
|
+
"token_endpoint_auth_method": "none", # public client — device-code, no secret
|
|
171
|
+
}
|
|
172
|
+
if scopes:
|
|
173
|
+
payload["scope"] = " ".join(scopes)
|
|
174
|
+
try:
|
|
175
|
+
resp = client.post(meta.registration_endpoint, json=payload)
|
|
176
|
+
resp.raise_for_status()
|
|
177
|
+
body = resp.json()
|
|
178
|
+
except (httpx.HTTPError, ValueError) as exc: # ValueError: a non-JSON body
|
|
179
|
+
raise DiscoveryError(f"dynamic client registration failed: {exc}") from exc
|
|
180
|
+
client_id = body.get("client_id")
|
|
181
|
+
if not client_id:
|
|
182
|
+
raise DiscoveryError("registration endpoint returned no client_id")
|
|
183
|
+
return client_id
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def start_device_authorization(
|
|
187
|
+
meta: AuthMetadata, client_id: str, *, client: httpx.Client, scopes: Optional[list[str]] = None
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
"""RFC 8628 device-authorization request → the device/user codes + polling parameters."""
|
|
190
|
+
if not meta.device_authorization_endpoint:
|
|
191
|
+
raise DeviceAuthError(
|
|
192
|
+
"the authorization server advertises no device_authorization_endpoint — "
|
|
193
|
+
"device-code login is unavailable",
|
|
194
|
+
reason="device_flow_unsupported",
|
|
195
|
+
)
|
|
196
|
+
data = {"client_id": client_id}
|
|
197
|
+
if scopes:
|
|
198
|
+
data["scope"] = " ".join(scopes)
|
|
199
|
+
try:
|
|
200
|
+
resp = client.post(meta.device_authorization_endpoint, data=data)
|
|
201
|
+
resp.raise_for_status()
|
|
202
|
+
body = resp.json()
|
|
203
|
+
except (httpx.HTTPError, ValueError) as exc: # ValueError: a non-JSON body
|
|
204
|
+
raise DeviceAuthError(f"device-authorization request failed: {exc}") from exc
|
|
205
|
+
if not body.get("device_code") or not body.get("user_code"):
|
|
206
|
+
raise DeviceAuthError("device-authorization response missing device_code/user_code")
|
|
207
|
+
return body
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def poll_for_token(
|
|
211
|
+
meta: AuthMetadata,
|
|
212
|
+
client_id: str,
|
|
213
|
+
device_code: str,
|
|
214
|
+
*,
|
|
215
|
+
client: httpx.Client,
|
|
216
|
+
interval: int = 5,
|
|
217
|
+
expires_in: int = 600,
|
|
218
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
219
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
220
|
+
) -> dict[str, Any]:
|
|
221
|
+
"""RFC 8628 polling: hit the token endpoint until success, denial, or expiry.
|
|
222
|
+
|
|
223
|
+
``sleep`` / ``monotonic`` are injectable so tests drive the loop without real waits.
|
|
224
|
+
"""
|
|
225
|
+
deadline = monotonic() + expires_in
|
|
226
|
+
wait = max(1, interval)
|
|
227
|
+
while True:
|
|
228
|
+
resp = client.post(
|
|
229
|
+
meta.token_endpoint,
|
|
230
|
+
data={
|
|
231
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
232
|
+
"device_code": device_code,
|
|
233
|
+
"client_id": client_id,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
try:
|
|
237
|
+
body: dict[str, Any] = resp.json() if resp.content else {}
|
|
238
|
+
except ValueError:
|
|
239
|
+
# A non-JSON body (e.g. a proxy's HTML error page) → fall through to the status-code
|
|
240
|
+
# error below rather than crashing out of the exit-code contract.
|
|
241
|
+
body = {}
|
|
242
|
+
if resp.status_code == 200 and body.get("access_token"):
|
|
243
|
+
return body
|
|
244
|
+
|
|
245
|
+
error = body.get("error", "")
|
|
246
|
+
if error == "authorization_pending":
|
|
247
|
+
pass
|
|
248
|
+
elif error == "slow_down":
|
|
249
|
+
wait += 5
|
|
250
|
+
elif error == "access_denied":
|
|
251
|
+
raise DeviceAuthError("the user denied the authorization request", reason="access_denied")
|
|
252
|
+
elif error == "expired_token":
|
|
253
|
+
raise DeviceAuthError("the device code expired before approval", reason="expired_token")
|
|
254
|
+
else:
|
|
255
|
+
raise DeviceAuthError(
|
|
256
|
+
f"token endpoint returned an unexpected error: {error or resp.status_code}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if monotonic() >= deadline:
|
|
260
|
+
raise DeviceAuthError("timed out waiting for device-code approval", reason="timeout")
|
|
261
|
+
sleep(wait)
|
kx_auth_cli/envelope.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The one output boundary every ``kx`` command runs behind.
|
|
2
|
+
|
|
3
|
+
The published contract (public/CLAUDE.md, docs/KX_AUTH_CLI.md): every command supports ``--json`` and
|
|
4
|
+
prints exactly one envelope on stdout --
|
|
5
|
+
|
|
6
|
+
{"status": "ok", "result": {...}} success
|
|
7
|
+
{"status": <status>, "reason": "..."} failure
|
|
8
|
+
{"status": "denied", "result": {...}} a refusal that carries the decision it made
|
|
9
|
+
{"status": "error", "result": {...}, "reason": "..."} ``rbac verify --fail-on``: worked, but unacceptable
|
|
10
|
+
|
|
11
|
+
-- with ``status`` in ``ok | error | auth_required | denied`` and the exit code ``0`` ok, ``1`` error,
|
|
12
|
+
``2`` usage, ``3`` auth-required, ``4`` denied. Without ``--json`` a success prints the result as JSON
|
|
13
|
+
(or ``ok`` when there is nothing to return) to stdout and a failure prints ``<status>: <reason>`` to
|
|
14
|
+
stderr, so stdout is parseable either way. ``"error"`` is the status for BOTH exit 1 and exit 2: the
|
|
15
|
+
code, not the text, is what a caller branches on.
|
|
16
|
+
|
|
17
|
+
A command expresses an outcome by returning its result or raising a :class:`CliError`; :func:`guarded`
|
|
18
|
+
turns either into the envelope and the exit code. Nothing else in the package prints an envelope and no
|
|
19
|
+
command holds its own exit constants, so a new command cannot get the contract wrong by construction.
|
|
20
|
+
Stdlib only, so the base install stays fastmcp-free.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
from typing import Any, Callable
|
|
29
|
+
|
|
30
|
+
EXIT_OK = 0
|
|
31
|
+
EXIT_ERROR = 1
|
|
32
|
+
EXIT_USAGE = 2
|
|
33
|
+
EXIT_AUTH_REQUIRED = 3
|
|
34
|
+
EXIT_DENIED = 4
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CliError(Exception):
|
|
38
|
+
"""A failure the command has classified. The base class IS the operational error (exit 1).
|
|
39
|
+
|
|
40
|
+
``result`` lets a refusal carry the decision it made: ``rbac check`` answers a denial with the
|
|
41
|
+
engine's own ``{"allowed": false, ...}`` and ``verify --fail-on`` fails a pipeline while still
|
|
42
|
+
reporting its findings, so the envelope loses nothing a caller could act on.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
status = "error"
|
|
46
|
+
exit_code = EXIT_ERROR
|
|
47
|
+
|
|
48
|
+
def __init__(self, reason: str | None = None, *, result: Any = None) -> None:
|
|
49
|
+
# Exception(None) stringifies as "None"; a reason-less refusal must read as "" instead.
|
|
50
|
+
super().__init__(*(() if reason is None else (reason,)))
|
|
51
|
+
self.reason = reason
|
|
52
|
+
self.result = result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UsageError(CliError):
|
|
56
|
+
"""A value the CALLER typed is unusable (exit 2). Same "error" status as an operational failure:
|
|
57
|
+
the envelope does not distinguish the two, the exit code does. Deliberately NOT a ValueError, so
|
|
58
|
+
"usage" can only ever come from a site that classified it as such — a bare ValueError from a
|
|
59
|
+
dependency (the outbound seam raises one for a misconfiguration) is an operational error."""
|
|
60
|
+
|
|
61
|
+
exit_code = EXIT_USAGE
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AuthRequired(CliError):
|
|
65
|
+
"""No usable credential, or an expired one (exit 3)."""
|
|
66
|
+
|
|
67
|
+
status = "auth_required"
|
|
68
|
+
exit_code = EXIT_AUTH_REQUIRED
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Denied(CliError):
|
|
72
|
+
"""A valid caller was refused by policy (exit 4)."""
|
|
73
|
+
|
|
74
|
+
status = "denied"
|
|
75
|
+
exit_code = EXIT_DENIED
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def json_parent() -> argparse.ArgumentParser:
|
|
79
|
+
"""The single ``--json`` declaration; every leaf parser takes it via ``parents=``."""
|
|
80
|
+
parent = argparse.ArgumentParser(add_help=False)
|
|
81
|
+
parent.add_argument("--json", action="store_true", help="Emit the stable JSON envelope (the agent path).")
|
|
82
|
+
return parent
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def note(text: str) -> None:
|
|
86
|
+
"""An advisory for a human (a shadowed $KX_AUTH_TOKEN, login's device-code prompt). Always stderr,
|
|
87
|
+
so a ``--json`` stdout stays exactly one envelope."""
|
|
88
|
+
print(text, file=sys.stderr)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def emit(
|
|
92
|
+
args: argparse.Namespace,
|
|
93
|
+
status: str,
|
|
94
|
+
*,
|
|
95
|
+
result: Any = None,
|
|
96
|
+
reason: str | None = None,
|
|
97
|
+
human: Callable[[Any], str] | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Print the one envelope (``--json``) or its human rendering.
|
|
100
|
+
|
|
101
|
+
``human`` renders a SUCCESS for a terminal; the default is the result as JSON. A command supplies
|
|
102
|
+
one when the bare result is the wrong thing to show a terminal: ``exchange``'s result holds a
|
|
103
|
+
bearer that must not reach a CI log or a shell history file.
|
|
104
|
+
"""
|
|
105
|
+
if args.json:
|
|
106
|
+
envelope: dict[str, Any] = {"status": status}
|
|
107
|
+
if result is not None:
|
|
108
|
+
envelope["result"] = result
|
|
109
|
+
if reason is not None:
|
|
110
|
+
envelope["reason"] = reason
|
|
111
|
+
print(json.dumps(envelope, indent=2, default=str))
|
|
112
|
+
elif status == "ok":
|
|
113
|
+
if human is not None:
|
|
114
|
+
print(human(result))
|
|
115
|
+
elif result is not None:
|
|
116
|
+
print(json.dumps(result, indent=2, default=str))
|
|
117
|
+
else:
|
|
118
|
+
print("ok")
|
|
119
|
+
else:
|
|
120
|
+
print(status if reason is None else f"{status}: {reason}", file=sys.stderr)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def guarded(
|
|
124
|
+
args: argparse.Namespace,
|
|
125
|
+
fn: Callable[[], Any],
|
|
126
|
+
*,
|
|
127
|
+
human: Callable[[Any], str] | None = None,
|
|
128
|
+
) -> int:
|
|
129
|
+
"""Run one command body and own its envelope and exit code.
|
|
130
|
+
|
|
131
|
+
``fn`` returns the success result (``None`` for a bare ``ok``) or raises a :class:`CliError`.
|
|
132
|
+
Anything else it raises is an operational error: the contract is one envelope and a documented
|
|
133
|
+
code, never a traceback, so the residue lands here rather than in ``cli.main``, which cannot know a
|
|
134
|
+
command's shape or code.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
result = fn()
|
|
138
|
+
except CliError as exc:
|
|
139
|
+
emit(args, exc.status, result=exc.result, reason=exc.reason)
|
|
140
|
+
return exc.exit_code
|
|
141
|
+
except Exception as exc: # noqa: BLE001 -- the boundary is the point
|
|
142
|
+
emit(args, "error", reason=f"{type(exc).__name__}: {exc}")
|
|
143
|
+
return EXIT_ERROR
|
|
144
|
+
emit(args, "ok", result=result, human=human)
|
|
145
|
+
return EXIT_OK
|
kx_auth_cli/exchange.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""``kx auth exchange`` — swap a subject token for a backend-scoped credential (RFC 8693 from a shell).
|
|
2
|
+
|
|
3
|
+
A thin wrapper over the shared :func:`kx_auth_core.exchange` seam — the *same* outbound code path a
|
|
4
|
+
KX MCP server's backends use — so the wire shape and audit chain are one implementation. Default strategy
|
|
5
|
+
is ``rfc_8693`` (the workload-identity bootstrap target: ``kubectl create token`` / ``gh
|
|
6
|
+
actions-token`` → ``kx auth exchange`` → backend-scoped token); ``passthrough`` / ``service_account``
|
|
7
|
+
/ custom registered strategies are selectable too.
|
|
8
|
+
|
|
9
|
+
Subject-token source, in order of **explicitness** (see ``_resolve_subject``): ``--subject`` → piped
|
|
10
|
+
stdin → ``$KX_AUTH_TOKEN`` → the cached ``login`` token for ``--server`` (the seamless ``login`` →
|
|
11
|
+
``exchange`` chain). ``service_account`` needs no subject. Maps the seam's result to the stable exit-code contract: ``0`` ok · ``1`` error ·
|
|
12
|
+
``2`` usage · ``3`` auth-required (the only subject candidate was an expired ``login`` cache entry) ·
|
|
13
|
+
``4`` denied (a ``passthrough`` audience-guard refusal).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import asyncio
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from kx_auth_core import OutboundConfig, exchange, outbound_strategies
|
|
25
|
+
|
|
26
|
+
from . import cache
|
|
27
|
+
from .envelope import AuthRequired, CliError, Denied, UsageError, guarded, note
|
|
28
|
+
|
|
29
|
+
_NEEDS_SUBJECT = {"passthrough", "rfc_8693"}
|
|
30
|
+
|
|
31
|
+
HELP_DESCRIPTION = (
|
|
32
|
+
"Exchange a subject token for a backend-scoped credential via the shared outbound seam "
|
|
33
|
+
"(one implementation, shared with the server side). Default strategy: rfc_8693."
|
|
34
|
+
)
|
|
35
|
+
HELP_EPILOG = """\
|
|
36
|
+
exit codes (branch on the code, not the text):
|
|
37
|
+
0 ok a credential was minted/forwarded
|
|
38
|
+
1 error misconfig, unreachable endpoint, or the endpoint returned no token
|
|
39
|
+
2 usage bad flags, an unrecognised --strategy, or no subject token for one that needs it
|
|
40
|
+
3 auth-required the cached login for --server has expired — `kx auth login` again
|
|
41
|
+
4 denied the strategy refused (e.g. passthrough audience-guard mismatch)
|
|
42
|
+
|
|
43
|
+
subject source, most explicit first: --subject -> piped stdin -> $KX_AUTH_TOKEN -> login cache (--server)
|
|
44
|
+
a pipe outranks $KX_AUTH_TOKEN, so a leftover token cannot shadow one you piped in; when both are
|
|
45
|
+
present the pipe is used and a note is written to stderr
|
|
46
|
+
|
|
47
|
+
strategies:
|
|
48
|
+
rfc_8693 (default) token exchange at --token-url for --audience; needs a subject token
|
|
49
|
+
passthrough forward the subject unchanged iff its aud already includes --audience
|
|
50
|
+
service_account client_credentials at --token-url (the caller's own identity; no subject)
|
|
51
|
+
|
|
52
|
+
examples:
|
|
53
|
+
# RFC 8693 swap (subject as a flag), structured output
|
|
54
|
+
kx auth exchange --subject "$TOK" --audience kdbai \\
|
|
55
|
+
--token-url https://idp/token --client-id mcp-container --client-secret "$SECRET" --json
|
|
56
|
+
|
|
57
|
+
# chain off a prior `kx auth login` — subject pulled from the cache for that server
|
|
58
|
+
kx auth login --server https://mcp.example --json
|
|
59
|
+
kx auth exchange --server https://mcp.example --audience backend-api --token-url https://idp/token --json
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
64
|
+
parser.add_argument("--subject", help="The subject (inbound) token. Falls back to piped stdin, then $KX_AUTH_TOKEN, then the login cache.")
|
|
65
|
+
parser.add_argument("--audience", help="Target backend audience (the token is scoped to this).")
|
|
66
|
+
parser.add_argument("--strategy", default="rfc_8693", help="Outbound strategy (default rfc_8693).")
|
|
67
|
+
parser.add_argument("--token-url", help="OIDC/STS token endpoint (rfc_8693 / service_account).")
|
|
68
|
+
parser.add_argument("--resource", help="RFC 8707 resource URI — an alternative to --audience.")
|
|
69
|
+
parser.add_argument("--client-id", help="Client id for authenticating at the token endpoint.")
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--client-secret",
|
|
72
|
+
default=os.environ.get("KX_AUTH_CLIENT_SECRET"),
|
|
73
|
+
help="Client secret paired with --client-id. Falls back to $KX_AUTH_CLIENT_SECRET "
|
|
74
|
+
"(preferred — keeps the secret out of shell history and process listings).",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--client-auth", choices=("post", "basic"), default="post",
|
|
78
|
+
help="How client credentials reach the token endpoint (default post).",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--scope", action="append", metavar="SCOPE",
|
|
82
|
+
help="Requested scope; repeatable, or comma/space-separated within one value.",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument("--server", help="MCP-server URL — used to pull the subject from the login cache.")
|
|
85
|
+
parser.add_argument("--no-verify", action="store_true", help="Disable TLS verification (dev IdPs).")
|
|
86
|
+
parser.add_argument("--timeout", type=float, default=30.0, help="Token-request timeout, seconds.")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_subject(args: argparse.Namespace) -> tuple[str | None, bool]:
|
|
90
|
+
"""Resolve the subject token; the second element flags that the only candidate was an
|
|
91
|
+
expired ``login``-cache entry (so the caller can map it to auth-required, not usage).
|
|
92
|
+
|
|
93
|
+
Precedence is by **explicitness**, not convenience: an explicit flag, then input piped at *this*
|
|
94
|
+
invocation, then the ambient environment, then the cache.
|
|
95
|
+
|
|
96
|
+
``$KX_AUTH_TOKEN`` deliberately ranks **below** stdin. It is inherited state — very often left over
|
|
97
|
+
from an earlier ``kx auth login``, or from the `exchange` → `assert` handoff itself — and while it
|
|
98
|
+
outranked a piped token, the workload-identity path (`platform token | kx auth exchange`) silently
|
|
99
|
+
exchanged the stale credential instead of the one it was handed. Wrong identity, no error. Ambient
|
|
100
|
+
state must never win over an argument the caller passed on purpose.
|
|
101
|
+
"""
|
|
102
|
+
if args.subject:
|
|
103
|
+
return args.subject, False
|
|
104
|
+
|
|
105
|
+
# isatty() keeps an interactive run from blocking on a terminal. A non-tty stdin carrying nothing
|
|
106
|
+
# reads as "" (or raises) and falls through to the environment. A caller who inherits an open, idle
|
|
107
|
+
# stdin blocks here — inherent to accepting piped input, and no worse than before this reordering,
|
|
108
|
+
# which only changes WHICH source wins once stdin has actually yielded something.
|
|
109
|
+
piped: str | None = None
|
|
110
|
+
try:
|
|
111
|
+
if not sys.stdin.isatty():
|
|
112
|
+
piped = sys.stdin.read().strip() or None
|
|
113
|
+
except (OSError, ValueError):
|
|
114
|
+
piped = None
|
|
115
|
+
|
|
116
|
+
env = (os.environ.get("KX_AUTH_TOKEN") or "").strip() or None
|
|
117
|
+
|
|
118
|
+
if piped:
|
|
119
|
+
# Say so rather than resolving the ambiguity silently — on stderr, so a --json envelope on
|
|
120
|
+
# stdout stays machine-parseable (the same split `login` uses for its device-code prompt).
|
|
121
|
+
if env and env != piped:
|
|
122
|
+
note(
|
|
123
|
+
"note: using the piped subject token; $KX_AUTH_TOKEN is also set and was ignored. "
|
|
124
|
+
"Pass --subject to be explicit."
|
|
125
|
+
)
|
|
126
|
+
return piped, False
|
|
127
|
+
if env:
|
|
128
|
+
return env, False
|
|
129
|
+
|
|
130
|
+
if args.server:
|
|
131
|
+
entry = cache.get(args.server)
|
|
132
|
+
if entry and entry.get("access_token"):
|
|
133
|
+
expires_at = entry.get("expires_at")
|
|
134
|
+
if expires_at:
|
|
135
|
+
try:
|
|
136
|
+
expired = time.time() >= float(expires_at)
|
|
137
|
+
except (TypeError, ValueError):
|
|
138
|
+
# An expiry that will not read is not a usable credential: report it as
|
|
139
|
+
# auth-required rather than sending a token whose lifetime is unknown.
|
|
140
|
+
return None, True
|
|
141
|
+
if expired:
|
|
142
|
+
return None, True
|
|
143
|
+
return entry["access_token"], False
|
|
144
|
+
return None, False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _scopes(args: argparse.Namespace) -> list[str] | None:
|
|
148
|
+
if not args.scope:
|
|
149
|
+
return None
|
|
150
|
+
out: list[str] = []
|
|
151
|
+
for chunk in args.scope:
|
|
152
|
+
out.extend(part for part in chunk.replace(",", " ").split() if part)
|
|
153
|
+
return out or None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _config_from_args(args: argparse.Namespace) -> OutboundConfig:
|
|
157
|
+
return OutboundConfig(
|
|
158
|
+
strategy=args.strategy,
|
|
159
|
+
token_url=args.token_url,
|
|
160
|
+
audience=args.audience,
|
|
161
|
+
resource=args.resource,
|
|
162
|
+
client_id=args.client_id,
|
|
163
|
+
client_secret=args.client_secret,
|
|
164
|
+
client_auth=args.client_auth,
|
|
165
|
+
scopes=_scopes(args),
|
|
166
|
+
verify=not args.no_verify,
|
|
167
|
+
timeout=args.timeout,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _human(result: dict) -> str:
|
|
172
|
+
# No access_token here: this line goes to a terminal, a CI log and a shell history file. Anyone who
|
|
173
|
+
# needs the token itself is already using --json.
|
|
174
|
+
return f"ok — strategy={result['strategy']} token_type={result['token_type']} expires_in={result['expires_in']}"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def run(args: argparse.Namespace) -> int:
|
|
178
|
+
def fn() -> dict:
|
|
179
|
+
# Checked against the registry, never restated here: a typo like "rfc8693" is not in
|
|
180
|
+
# `_NEEDS_SUBJECT` either, so without this it would skip the subject check below and surface as
|
|
181
|
+
# a generic exit-1 failure deep in the outbound seam instead of a usage error naming the typo.
|
|
182
|
+
known = outbound_strategies()
|
|
183
|
+
if args.strategy not in known:
|
|
184
|
+
raise UsageError(f"unknown --strategy {args.strategy!r}; choose one of: {', '.join(sorted(known))}")
|
|
185
|
+
|
|
186
|
+
subject, cache_expired = _resolve_subject(args)
|
|
187
|
+
if args.strategy in _NEEDS_SUBJECT and not subject:
|
|
188
|
+
if cache_expired:
|
|
189
|
+
raise AuthRequired(
|
|
190
|
+
f"the cached login for {args.server} has expired — "
|
|
191
|
+
f"run `kx auth login --server {args.server}` again"
|
|
192
|
+
)
|
|
193
|
+
raise UsageError(
|
|
194
|
+
f"strategy '{args.strategy}' needs a subject token "
|
|
195
|
+
"(pass --subject, $KX_AUTH_TOKEN, stdin, or --server for the login cache)"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
credential = asyncio.run(exchange(_config_from_args(args), subject))
|
|
200
|
+
except PermissionError as exc: # strategy refusal (e.g. passthrough audience guard)
|
|
201
|
+
raise Denied(str(exc)) from exc
|
|
202
|
+
except Exception as exc: # the seam's ValueError (misconfig / no token), httpx, unexpected
|
|
203
|
+
raise CliError(str(exc)) from exc
|
|
204
|
+
return {
|
|
205
|
+
"access_token": credential.access_token,
|
|
206
|
+
"token_type": credential.token_type,
|
|
207
|
+
"expires_in": credential.expires_in,
|
|
208
|
+
"strategy": credential.strategy,
|
|
209
|
+
"claims": credential.claims,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return guarded(args, fn, human=_human)
|