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,162 @@
1
+ """``kx auth introspect`` — decode/validate a bearer against the configured ``KX_MCP_AUTH`` keys.
2
+
3
+ Reuses the shared :func:`kx_auth_core.verify_token` — the same validation a KX MCP server enforces on
4
+ the way in, so a verdict here is the verdict there — and maps its categorised verdict to the stable
5
+ exit-code contract:
6
+
7
+ * ``0`` valid · ``1`` error (malformed / bad signature / unreachable JWKS) · ``2`` usage (no token) ·
8
+ ``3`` auth-required (expired — the agent should ``login``) · ``4`` denied (issuer / audience /
9
+ required-scope mismatch).
10
+
11
+ Config defaults come from the ``KX_MCP_AUTH*`` environment — the same variables a KX MCP server
12
+ reads, so pointing both at one environment checks against one set of keys; the flags below override
13
+ per-invocation. ``introspect`` is stateless — it does not touch the token cache. (Server-advertised
14
+ RFC 9728 discovery is handled by ``login``.)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import os
21
+ import sys
22
+
23
+ from kx_auth_core import AUTH_REQUIRED, DENIED, AuthSettings, verify_token
24
+
25
+ from .envelope import AuthRequired, CliError, Denied, UsageError, guarded, note
26
+
27
+ # CLI help kept separate from the module docstring above: the docstring is RST for code/IDE readers;
28
+ # this is clean plaintext for the terminal (and for an agent reading `--help`). RawDescriptionHelpFormatter
29
+ # prints the epilog verbatim, so the alignment below is preserved.
30
+ HELP_DESCRIPTION = (
31
+ "Validate a bearer token against the same keys a KX MCP server enforces and report a "
32
+ "machine-readable verdict. Stateless — does not touch any token cache."
33
+ )
34
+ HELP_EPILOG = """\
35
+ exit codes (branch on the code, not the text):
36
+ 0 ok valid
37
+ 1 error malformed / bad signature / unreachable JWKS
38
+ 2 usage bad flags, or no token supplied
39
+ 3 auth-required well-signed but expired — re-authenticate (kx auth login)
40
+ 4 denied well-signed but wrong issuer / audience / required scope
41
+
42
+ token source, most explicit first: argument -> piped stdin -> $KX_AUTH_TOKEN
43
+ a pipe outranks $KX_AUTH_TOKEN (same rule as `exchange`); when both are set the pipe is used and a
44
+ note is written to stderr
45
+
46
+ config comes from the environment (the same vars the container reads); flags override per call:
47
+ --public-key-path KX_MCP_AUTH_PUBLIC_KEY_PATH static mode (PEM on disk)
48
+ --public-key KX_MCP_AUTH_PUBLIC_KEY static mode (inline PEM)
49
+ --jwks-uri KX_MCP_AUTH_JWKS_URI jwks mode (remote endpoint)
50
+ --issuer KX_MCP_AUTH_ISSUER
51
+ --audience KX_MCP_AUTH_AUDIENCE
52
+ --algorithm KX_MCP_AUTH_ALGORITHM default RS256
53
+ --required-scopes KX_MCP_AUTH_REQUIRED_SCOPES comma/space separated
54
+
55
+ examples:
56
+ # token + key as flags, structured verdict
57
+ kx auth introspect "$TOKEN" --public-key-path key.pub --issuer https://idp --audience my-api --json
58
+
59
+ # config + token entirely from the environment (matches the container)
60
+ KX_MCP_AUTH=static KX_MCP_AUTH_PUBLIC_KEY_PATH=key.pub \\
61
+ KX_AUTH_TOKEN="$TOKEN" kx auth introspect --json
62
+
63
+ # validate against a live JWKS endpoint, token piped on stdin
64
+ echo "$TOKEN" | kx auth introspect --jwks-uri https://idp/.well-known/jwks.json
65
+ """
66
+
67
+ # kx_auth_core's verdict categories, minus OK (a success) and ERROR (the default CliError).
68
+ _FAILURES = {AUTH_REQUIRED: AuthRequired, DENIED: Denied}
69
+
70
+
71
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
72
+ parser.add_argument(
73
+ "token",
74
+ nargs="?",
75
+ help="The bearer token to validate. Falls back to piped stdin, then $KX_AUTH_TOKEN.",
76
+ )
77
+ # Verification config — overrides the KX_MCP_AUTH* environment for this invocation.
78
+ parser.add_argument("--jwks-uri", help="Remote JWKS endpoint (selects jwks mode).")
79
+ parser.add_argument("--public-key", help="Inline RS256 public-key PEM (selects static mode).")
80
+ parser.add_argument(
81
+ "--public-key-path", help="Path to an RS256 public-key PEM (selects static mode)."
82
+ )
83
+ parser.add_argument("--issuer", help="Expected `iss` claim.")
84
+ parser.add_argument("--audience", help="Expected `aud` claim.")
85
+ parser.add_argument("--algorithm", help="Signing algorithm to accept (default RS256).")
86
+ parser.add_argument(
87
+ "--required-scopes", help="Comma/space-separated scopes that must be present."
88
+ )
89
+
90
+
91
+ def _resolve_token(args: argparse.Namespace) -> str | None:
92
+ """Resolve the bearer to validate, in order of **explicitness**: argument → piped stdin →
93
+ ``$KX_AUTH_TOKEN``.
94
+
95
+ The environment ranks **below** a pipe, matching ``exchange``'s subject resolution — one rule across
96
+ the CLI, since an agent that learns one command's precedence will apply it to the other. It also
97
+ matters more here than it looks: ``introspect`` is the pre-flight ("does the token my gateway will
98
+ forward actually validate?"), so silently reporting a verdict about a *leftover* ``$KX_AUTH_TOKEN``
99
+ instead of the token piped in would answer a question nobody asked, and answer it convincingly.
100
+ """
101
+ if args.token:
102
+ return args.token
103
+
104
+ piped: str | None = None
105
+ try:
106
+ if not sys.stdin.isatty():
107
+ piped = sys.stdin.read().strip() or None
108
+ except (OSError, ValueError):
109
+ # stdin unavailable (e.g. captured under a test harness) — treat as "nothing piped".
110
+ piped = None
111
+
112
+ env = (os.environ.get("KX_AUTH_TOKEN") or "").strip() or None
113
+
114
+ if piped:
115
+ if env and env != piped:
116
+ note("note: validating the piped token; $KX_AUTH_TOKEN is also set and was ignored.")
117
+ return piped
118
+ return env
119
+
120
+
121
+ def _settings_from_args(args: argparse.Namespace) -> AuthSettings:
122
+ """Start from the KX_MCP_AUTH* environment, then apply explicit flag overrides. Key material on
123
+ the CLI selects the mode, so `introspect <token> --public-key-path k.pub` works with no env."""
124
+ overrides: dict = {}
125
+ for flag in ("jwks_uri", "public_key", "public_key_path", "issuer", "audience"):
126
+ value = getattr(args, flag)
127
+ if value is not None:
128
+ overrides[flag] = value
129
+ if args.algorithm:
130
+ overrides["algorithm"] = args.algorithm
131
+ if args.required_scopes:
132
+ overrides["required_scopes"] = args.required_scopes
133
+ if args.public_key or args.public_key_path:
134
+ overrides["mode"] = "static"
135
+ elif args.jwks_uri:
136
+ overrides["mode"] = "jwks"
137
+ return AuthSettings(**overrides)
138
+
139
+
140
+ def _human(result: dict) -> str:
141
+ return f"valid — client_id={result['client_id']} scopes={result['scopes'] or []}"
142
+
143
+
144
+ def run(args: argparse.Namespace) -> int:
145
+ def fn() -> dict:
146
+ token = _resolve_token(args)
147
+ if not token:
148
+ raise UsageError("no token provided (pass as an argument, $KX_AUTH_TOKEN, or via stdin)")
149
+ try:
150
+ verdict = verify_token(token, _settings_from_args(args))
151
+ except Exception as exc: # config / unexpected failure → error, with the message as it came
152
+ raise CliError(str(exc)) from exc
153
+ if not verdict.valid:
154
+ raise _FAILURES.get(verdict.category, CliError)(verdict.reason)
155
+ return {
156
+ "valid": True,
157
+ "client_id": verdict.client_id,
158
+ "scopes": verdict.scopes,
159
+ "claims": verdict.claims,
160
+ }
161
+
162
+ return guarded(args, fn, human=_human)
kx_auth_cli/login.py ADDED
@@ -0,0 +1,153 @@
1
+ """``kx auth login`` — acquire a bearer for an MCP server via server-advertised device-code OAuth.
2
+
3
+ The mediation model the MCP spec mandates (see ``discovery``): point ``kx auth`` at the **MCP server
4
+ (the resource server)** you connect to — *not* a backend. That server publishes RFC 9728 metadata
5
+ naming its authorization server; ``kx auth`` discovers the AS and runs the **RFC 8628 device-code**
6
+ flow directly against it (never redirecting through the resource server). The token is audienced to
7
+ the MCP-server resource; backend-scoped tokens come from a separate ``kx auth exchange``.
8
+
9
+ Client identity is **DCR-first**: when the AS advertises a ``registration_endpoint`` and no
10
+ ``--client-id`` is given, register dynamically (RFC 7591) so the user never hand-configures a
11
+ client-id; ``--client-id`` / ``$KX_AUTH_CLIENT_ID`` overrides. The acquired credential is written to
12
+ the endpoint-keyed token cache for ``kx auth exchange`` to chain off.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import os
19
+ import time
20
+
21
+ import httpx
22
+
23
+ from . import cache, discovery
24
+ from .discovery import DeviceAuthError, DiscoveryError
25
+ from .envelope import AuthRequired, CliError, Denied, guarded, note
26
+
27
+ # DeviceAuthError.reason → how the refusal is classified (anything unlisted is a plain error).
28
+ _DEVICE_FAILURES = {
29
+ "access_denied": Denied,
30
+ "expired_token": AuthRequired,
31
+ "timeout": AuthRequired,
32
+ }
33
+
34
+ HELP_DESCRIPTION = (
35
+ "Acquire a bearer for an MCP server via the device-code flow against the authorization server "
36
+ "the server advertises (RFC 9728 discovery → RFC 8628). Caches the token for `exchange`."
37
+ )
38
+ HELP_EPILOG = """\
39
+ exit codes (branch on the code, not the text):
40
+ 0 ok a token was acquired and cached
41
+ 1 error discovery failed, unreachable endpoint, no device-code support, or DCR failure
42
+ 2 usage bad flags
43
+ 3 auth-required the device code expired / timed out before approval — retry
44
+ 4 denied the user denied the authorization request
45
+
46
+ --server is the MCP-server (resource) URL you connect to — NOT a backend. The client talks directly
47
+ to the discovered authorization server; the token is audienced to that resource. Use `kx auth
48
+ exchange` for backend-scoped tokens.
49
+
50
+ client identity (DCR-first): RFC 7591 dynamic registration if the AS supports it, else --client-id
51
+ (or $KX_AUTH_CLIENT_ID).
52
+
53
+ examples:
54
+ kx auth login --server https://mcp.example --json
55
+ kx auth login --server https://mcp.example --client-id my-public-client --scope "kdbx.read offline_access"
56
+ """
57
+
58
+
59
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
60
+ parser.add_argument("--server", required=True, help="MCP-server (resource) URL to authenticate to.")
61
+ parser.add_argument("--client-id", help="OAuth client id (overrides DCR; falls back to $KX_AUTH_CLIENT_ID).")
62
+ parser.add_argument(
63
+ "--scope", action="append", metavar="SCOPE",
64
+ help="Requested scope; repeatable, or comma/space-separated within one value.",
65
+ )
66
+ parser.add_argument("--no-verify", action="store_true", help="Disable TLS verification (dev IdPs).")
67
+ parser.add_argument("--timeout", type=float, default=30.0, help="Per-request timeout, seconds.")
68
+
69
+
70
+ def _scopes(args: argparse.Namespace) -> list[str] | None:
71
+ if not args.scope:
72
+ return None
73
+ out: list[str] = []
74
+ for chunk in args.scope:
75
+ out.extend(part for part in chunk.replace(",", " ").split() if part)
76
+ return out or None
77
+
78
+
79
+ def _prompt(device: dict) -> None:
80
+ """Show the user where to approve — to stderr, so a --json stdout stays clean."""
81
+ complete = device.get("verification_uri_complete")
82
+ uri = device.get("verification_uri", "<unknown>")
83
+ code = device.get("user_code", "<unknown>")
84
+ if complete:
85
+ note(f"To authorize, open: {complete}")
86
+ note(f"To authorize, visit {uri} and enter code: {code}")
87
+
88
+
89
+ def _acquire(args: argparse.Namespace) -> dict:
90
+ """The flow itself: discover, register, device-authorize, poll, cache. Returns the result."""
91
+ scopes = _scopes(args)
92
+ client_id = args.client_id or os.environ.get("KX_AUTH_CLIENT_ID")
93
+ with httpx.Client(verify=not args.no_verify, timeout=args.timeout) as client:
94
+ meta = discovery.discover(args.server, client=client)
95
+ if not client_id:
96
+ client_id = discovery.register_client(
97
+ meta, client=client, client_name="kx-auth-cli", scopes=scopes
98
+ )
99
+ device = discovery.start_device_authorization(meta, client_id, client=client, scopes=scopes)
100
+ _prompt(device)
101
+ token = discovery.poll_for_token(
102
+ meta,
103
+ client_id,
104
+ device["device_code"],
105
+ client=client,
106
+ interval=int(device.get("interval", 5)),
107
+ expires_in=int(device.get("expires_in", 600)),
108
+ )
109
+
110
+ expires_in = token.get("expires_in")
111
+ now = int(time.time())
112
+ credential = {
113
+ "access_token": token["access_token"],
114
+ "refresh_token": token.get("refresh_token"),
115
+ "token_type": token.get("token_type", "Bearer"),
116
+ "scope": token.get("scope"),
117
+ "expires_at": now + int(expires_in) if expires_in else None,
118
+ "issued_at": now,
119
+ "authorization_server": meta.authorization_server,
120
+ }
121
+ cache_path = cache.save(args.server, credential)
122
+ return {
123
+ "server": args.server,
124
+ "authorization_server": meta.authorization_server,
125
+ "access_token": token["access_token"],
126
+ "token_type": token.get("token_type", "Bearer"),
127
+ "expires_in": expires_in,
128
+ "cached": True,
129
+ "cache_path": str(cache_path),
130
+ }
131
+
132
+
133
+ def _human(result: dict) -> str:
134
+ return f"ok — logged in to {result['server']} (cached at {result['cache_path']})"
135
+
136
+
137
+ def run(args: argparse.Namespace) -> int:
138
+ def fn() -> dict:
139
+ try:
140
+ return _acquire(args)
141
+ except DiscoveryError as exc:
142
+ raise CliError(str(exc)) from exc
143
+ except DeviceAuthError as exc:
144
+ raise _DEVICE_FAILURES.get(exc.reason, CliError)(str(exc)) from exc
145
+ # httpx.InvalidURL does NOT subclass httpx.HTTPError. `discovery` rejects a malformed URL as
146
+ # a DiscoveryError before httpx sees it, but the exit-code contract is owned here, so this
147
+ # layer does not depend on that holding.
148
+ except (httpx.HTTPError, httpx.InvalidURL) as exc:
149
+ raise CliError(f"network error talking to the authorization server: {exc}") from exc
150
+ # Anything else — a token response missing `access_token`, a non-numeric `expires_in`, an
151
+ # unwritable cache — is the residue `guarded` files as an operational error.
152
+
153
+ return guarded(args, fn, human=_human)
kx_auth_cli/qbridge.py ADDED
@@ -0,0 +1,54 @@
1
+ """The two things every command that talks to q needs: a quiet PyKX import, and readable q values.
2
+
3
+ Both exist only because of how PyKX presents itself and how q presents its values, so they live
4
+ together rather than being restated by each command that connects.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import io
11
+ from typing import Any
12
+
13
+
14
+ def import_pykx() -> Any:
15
+ """Import PyKX without letting its banner reach stdout.
16
+
17
+ Community PyKX prints an embedded-q welcome banner while importing. Every command here has a
18
+ one-envelope stdout contract, so a library banner would corrupt `--json` output for a caller that
19
+ is parsing it — and it is irrelevant to a remote qIPC client in any case.
20
+
21
+ Raises whatever the import raised, so each caller can map it to its own error surface.
22
+ """
23
+ with contextlib.redirect_stdout(io.StringIO()):
24
+ import pykx as kx
25
+ return kx
26
+
27
+
28
+ def readable(obj: Any) -> Any:
29
+ """Convert a q readback into something JSON can render honestly.
30
+
31
+ Three q-to-Python artifacts have to be undone, or they leak into an interface contract:
32
+
33
+ * a PyKX wrapper that has not been converted yet (`.py()`),
34
+ * q char vectors, which arrive as `bytes` and would print as `b'...'`,
35
+ * numpy scalars and arrays, which `json.dumps` cannot serialise.
36
+
37
+ Containers are walked so a nested claims blob comes back clean.
38
+ """
39
+ if hasattr(obj, "py"):
40
+ obj = obj.py()
41
+ if isinstance(obj, bytes):
42
+ return obj.decode("utf-8", "replace")
43
+ if isinstance(obj, dict):
44
+ return {readable(k): readable(v) for k, v in obj.items()}
45
+ if isinstance(obj, (list, tuple)):
46
+ return [readable(v) for v in obj]
47
+ if hasattr(obj, "item"):
48
+ try:
49
+ return readable(obj.item())
50
+ except (TypeError, ValueError):
51
+ pass
52
+ if hasattr(obj, "tolist"):
53
+ return readable(obj.tolist())
54
+ return obj