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.
@@ -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)
@@ -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
@@ -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)