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