kx-auth-cli 0.5.0b1__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 +7 -0
- kx_auth_cli/assert_cmd.py +209 -0
- kx_auth_cli/cache.py +60 -0
- kx_auth_cli/cli.py +85 -0
- kx_auth_cli/discovery.py +231 -0
- kx_auth_cli/exchange.py +193 -0
- kx_auth_cli/introspect.py +167 -0
- kx_auth_cli/login.py +170 -0
- kx_auth_cli-0.5.0b1.dist-info/METADATA +225 -0
- kx_auth_cli-0.5.0b1.dist-info/RECORD +12 -0
- kx_auth_cli-0.5.0b1.dist-info/WHEEL +4 -0
- kx_auth_cli-0.5.0b1.dist-info/entry_points.txt +2 -0
kx_auth_cli/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""``kx auth`` — the agent-facing auth CLI for the kx-mcp control plane.
|
|
2
|
+
|
|
3
|
+
A thin ``kx`` umbrella (stdlib argparse) whose ``auth`` group exposes the auth flows as a headless
|
|
4
|
+
surface for an autonomous MCP client: structured ``--json`` output and a stable exit-code contract so
|
|
5
|
+
the agent branches on codes, not prose. Subcommands: ``introspect``, ``login``, ``exchange``,
|
|
6
|
+
``assert``. Verification reuses the lean :mod:`kx_auth_core` (no fastmcp).
|
|
7
|
+
"""
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""``kx auth assert`` — exercise the kdb+ identity-assertion handshake from a shell.
|
|
2
|
+
|
|
3
|
+
plain kdb+ has no bearer over qIPC, so the container does not mint a token for it — it **asserts**
|
|
4
|
+
the caller's identity: projects the validated claims into a q dict and binds them to a trusted
|
|
5
|
+
service-account connection via ``.kx.auth.bind[principal]``. This command runs that handshake
|
|
6
|
+
end-to-end *without* a full container, so an operator or CI step can verify a kdb+ target's
|
|
7
|
+
``kx.auth`` module + permission functions behave as expected for a given principal.
|
|
8
|
+
|
|
9
|
+
Two modes:
|
|
10
|
+
|
|
11
|
+
- **project-only** (no ``--connect``): build and print the principal wire-dict the container *would*
|
|
12
|
+
bind. Uses the shared, fastmcp-free projection — always available, no PyKX needed.
|
|
13
|
+
- **handshake** (``--connect host:port``): connect as the service account, call ``.kx.auth.bind``,
|
|
14
|
+
then read back ``.kx.auth.current[]`` / ``.kx.auth.valid[]`` (and optionally run a ``--probe``
|
|
15
|
+
query) to confirm the assertion took. The qIPC leg needs PyKX, pulled in via the optional
|
|
16
|
+
``kx-auth-cli[qipc]`` extra so the base CLI stays light + fastmcp-free.
|
|
17
|
+
|
|
18
|
+
Claims source (first found wins): ``--principal`` JSON (``-`` for stdin, ``@file`` for a file) →
|
|
19
|
+
``--token`` (a JWT, decoded **unverified** — this is a local handshake exerciser, not a validator) →
|
|
20
|
+
``$KX_AUTH_TOKEN`` (as a JWT). Exit codes: ``0`` ok · ``1`` error · ``2`` usage · ``4`` denied
|
|
21
|
+
(a ``--probe`` query refused by the q-side permission check).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
from kx_auth_core import decode_claims_unverified, project_from_claims
|
|
32
|
+
|
|
33
|
+
EXIT_OK = 0
|
|
34
|
+
EXIT_ERROR = 1
|
|
35
|
+
EXIT_USAGE = 2
|
|
36
|
+
EXIT_DENIED = 4
|
|
37
|
+
|
|
38
|
+
HELP_DESCRIPTION = (
|
|
39
|
+
"Exercise the kdb+ identity-assertion handshake: project a principal into the q wire-dict and "
|
|
40
|
+
"(with --connect) bind it on a service-account connection to verify a target's kx.auth module."
|
|
41
|
+
)
|
|
42
|
+
HELP_EPILOG = """\
|
|
43
|
+
exit codes (branch on the code, not the text):
|
|
44
|
+
0 ok projected (project-only), or connected + bound + asserted
|
|
45
|
+
1 error bad claims, target missing .kx.auth, connect failure, or PyKX not installed
|
|
46
|
+
2 usage no principal/claims supplied
|
|
47
|
+
4 denied a --probe query was refused by the q-side permission check
|
|
48
|
+
|
|
49
|
+
claims source (first found wins): --principal JSON -> --token JWT -> $KX_AUTH_TOKEN (JWT)
|
|
50
|
+
--principal '-' read JSON claims from stdin
|
|
51
|
+
--principal @file.json read JSON claims from a file
|
|
52
|
+
|
|
53
|
+
examples:
|
|
54
|
+
# project-only: see exactly what would be bound (no kdb+, no PyKX)
|
|
55
|
+
kx auth assert --principal '{"sub":"alice","scope":"kdbx.read","aud":"kx-mcp"}' --json
|
|
56
|
+
|
|
57
|
+
# full handshake against a kdb+ carrying the kx.auth module (needs kx-auth-cli[qipc])
|
|
58
|
+
kx auth assert --token "$TOK" --connect localhost:5010 \\
|
|
59
|
+
--user kxmcp --password "$SVC_PW" --probe "select from trades" --json
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
64
|
+
parser.add_argument("--principal", help="Principal claims as JSON ('-' for stdin, '@file' for a file).")
|
|
65
|
+
parser.add_argument("--token", help="A JWT to decode (UNVERIFIED) into claims — convenience for a local handshake.")
|
|
66
|
+
parser.add_argument("--connect", metavar="HOST:PORT", help="kdb+ target to run the bind handshake against (needs the [qipc] extra).")
|
|
67
|
+
parser.add_argument("--user", default=os.environ.get("KX_AUTH_KDB_USER", ""), help="Service-account user for the qIPC login (or $KX_AUTH_KDB_USER).")
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--password",
|
|
70
|
+
default=os.environ.get("KX_AUTH_KDB_PASSWORD"),
|
|
71
|
+
help="Service-account password (or $KX_AUTH_KDB_PASSWORD — preferred, keeps it out of shell history).",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument("--tls", action="store_true", help="Use TLS for the qIPC connection.")
|
|
74
|
+
parser.add_argument("--probe", help="Optional q expression to run after binding (a denial maps to exit 4).")
|
|
75
|
+
parser.add_argument("--timeout", type=float, default=5.0, help="qIPC connection timeout, seconds.")
|
|
76
|
+
parser.add_argument("--json", action="store_true", help="Emit a structured JSON envelope (the agent path).")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_claims(args: argparse.Namespace) -> tuple[dict | None, str | None]:
|
|
80
|
+
"""Return (claims, error). Claims source: --principal JSON, then --token, then $KX_AUTH_TOKEN."""
|
|
81
|
+
if args.principal:
|
|
82
|
+
raw = args.principal
|
|
83
|
+
if raw == "-":
|
|
84
|
+
raw = sys.stdin.read()
|
|
85
|
+
elif raw.startswith("@"):
|
|
86
|
+
try:
|
|
87
|
+
with open(raw[1:], "r") as fh:
|
|
88
|
+
raw = fh.read()
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
return None, f"cannot read principal file: {exc}"
|
|
91
|
+
try:
|
|
92
|
+
claims = json.loads(raw)
|
|
93
|
+
except json.JSONDecodeError as exc:
|
|
94
|
+
return None, f"--principal is not valid JSON: {exc}"
|
|
95
|
+
if not isinstance(claims, dict):
|
|
96
|
+
return None, "--principal JSON must be an object of claims"
|
|
97
|
+
return claims, None
|
|
98
|
+
|
|
99
|
+
token = args.token or os.environ.get("KX_AUTH_TOKEN")
|
|
100
|
+
if token:
|
|
101
|
+
claims = decode_claims_unverified(token.strip())
|
|
102
|
+
if not claims:
|
|
103
|
+
return None, "--token did not decode to any claims (opaque or malformed JWT)"
|
|
104
|
+
return claims, None
|
|
105
|
+
|
|
106
|
+
return None, None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _emit(args: argparse.Namespace, *, status: str, principal=None, reason=None, **extra) -> None:
|
|
110
|
+
if args.json:
|
|
111
|
+
envelope = {"status": status}
|
|
112
|
+
if principal is not None:
|
|
113
|
+
envelope["principal"] = principal
|
|
114
|
+
if reason is not None:
|
|
115
|
+
envelope["reason"] = reason
|
|
116
|
+
envelope.update(extra)
|
|
117
|
+
print(json.dumps(envelope, indent=2, default=str))
|
|
118
|
+
return
|
|
119
|
+
if status == "ok":
|
|
120
|
+
print(f"ok — principal sub={principal.get('sub')!r}", end="")
|
|
121
|
+
if extra:
|
|
122
|
+
print(" " + " ".join(f"{k}={v}" for k, v in extra.items()))
|
|
123
|
+
else:
|
|
124
|
+
print()
|
|
125
|
+
else:
|
|
126
|
+
print(f"{status}: {reason}", file=sys.stderr)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _handshake(args: argparse.Namespace, wire: dict) -> int:
|
|
130
|
+
"""Connect as the service account, bind the principal, confirm, optionally probe."""
|
|
131
|
+
try:
|
|
132
|
+
import pykx as kx
|
|
133
|
+
except Exception:
|
|
134
|
+
_emit(
|
|
135
|
+
args,
|
|
136
|
+
status="error",
|
|
137
|
+
principal=wire,
|
|
138
|
+
reason="PyKX is required for --connect; install the qIPC extra: pip install 'kx-auth-cli[qipc]'",
|
|
139
|
+
)
|
|
140
|
+
return EXIT_ERROR
|
|
141
|
+
|
|
142
|
+
host, _, port = args.connect.partition(":")
|
|
143
|
+
if not port.isdigit():
|
|
144
|
+
_emit(args, status="error", reason="--connect must be HOST:PORT")
|
|
145
|
+
return EXIT_USAGE
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
conn = kx.SyncQConnection(
|
|
149
|
+
host=host or "localhost",
|
|
150
|
+
port=int(port),
|
|
151
|
+
username=args.user,
|
|
152
|
+
password=args.password or "",
|
|
153
|
+
timeout=args.timeout,
|
|
154
|
+
tls=args.tls,
|
|
155
|
+
)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
_emit(args, status="error", reason=f"connect failed: {exc}")
|
|
158
|
+
return EXIT_ERROR
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
conn(".kx.auth.bind", wire)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
_emit(
|
|
164
|
+
args,
|
|
165
|
+
status="error",
|
|
166
|
+
principal=wire,
|
|
167
|
+
reason=f"bind failed (target missing the kx.auth module?): {exc}",
|
|
168
|
+
)
|
|
169
|
+
return EXIT_ERROR
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
bound_valid = bool(conn(".kx.auth.valid[]").py())
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
_emit(args, status="error", principal=wire, reason=f"could not read .kx.auth.valid[]: {exc}")
|
|
175
|
+
return EXIT_ERROR
|
|
176
|
+
|
|
177
|
+
if args.probe:
|
|
178
|
+
try:
|
|
179
|
+
conn(args.probe)
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
if str(exc).strip().lower().startswith("denied"):
|
|
182
|
+
_emit(args, status="denied", principal=wire, reason=str(exc), bound=True, valid=bound_valid)
|
|
183
|
+
return EXIT_DENIED
|
|
184
|
+
_emit(args, status="error", principal=wire, reason=f"probe failed: {exc}")
|
|
185
|
+
return EXIT_ERROR
|
|
186
|
+
|
|
187
|
+
_emit(args, status="ok", principal=wire, bound=True, valid=bound_valid, probed=bool(args.probe))
|
|
188
|
+
return EXIT_OK
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run(args: argparse.Namespace) -> int:
|
|
192
|
+
claims, err = _resolve_claims(args)
|
|
193
|
+
if err is not None:
|
|
194
|
+
_emit(args, status="error", reason=err)
|
|
195
|
+
return EXIT_ERROR
|
|
196
|
+
if claims is None:
|
|
197
|
+
print(
|
|
198
|
+
"error: no principal supplied (pass --principal JSON, --token JWT, or $KX_AUTH_TOKEN)",
|
|
199
|
+
file=sys.stderr,
|
|
200
|
+
)
|
|
201
|
+
return EXIT_USAGE
|
|
202
|
+
|
|
203
|
+
wire = project_from_claims(claims)
|
|
204
|
+
|
|
205
|
+
if not args.connect:
|
|
206
|
+
_emit(args, status="ok", principal=wire)
|
|
207
|
+
return EXIT_OK
|
|
208
|
+
|
|
209
|
+
return _handshake(args, wire)
|
kx_auth_cli/cache.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Client-side token cache for ``kx auth`` — a single JSON file, keyed by endpoint.
|
|
2
|
+
|
|
3
|
+
``login`` writes the acquired credential here; ``exchange`` reads it as a subject-token fallback.
|
|
4
|
+
The cache is a *client-side* concern (the agent/operator's own credentials), so it lives in the CLI
|
|
5
|
+
rather than the shared core. Templates include product CLIs' endpoint-keyed caches and ``gh``'s
|
|
6
|
+
``~/.config/gh/hosts.yml`` — a single JSON file under a dotdir, path overridable, keyed by endpoint
|
|
7
|
+
so multiple deployments coexist. OS-keyring storage is a later hardening, not the v1 bar.
|
|
8
|
+
|
|
9
|
+
The keys are the ``--server`` (MCP-server / resource) URLs the agent connects to. ``introspect`` is
|
|
10
|
+
stateless and never touches this file.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cache_path() -> Path:
|
|
22
|
+
"""The credential file path: ``$KX_AUTH_CACHE`` if set, else ``~/.kx/credentials.json``."""
|
|
23
|
+
override = os.environ.get("KX_AUTH_CACHE")
|
|
24
|
+
if override:
|
|
25
|
+
return Path(override).expanduser()
|
|
26
|
+
return Path.home() / ".kx" / "credentials.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load() -> dict[str, Any]:
|
|
30
|
+
"""The whole cache as ``{server: credential}``; ``{}`` if absent or unreadable (never raises)."""
|
|
31
|
+
path = cache_path()
|
|
32
|
+
try:
|
|
33
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
34
|
+
data = json.load(fh)
|
|
35
|
+
return data if isinstance(data, dict) else {}
|
|
36
|
+
except (OSError, ValueError):
|
|
37
|
+
# Missing or corrupt cache is "no cached credentials", not a hard error.
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get(server: str) -> Optional[dict[str, Any]]:
|
|
42
|
+
"""The cached credential for ``server``, or ``None`` if not present."""
|
|
43
|
+
entry = load().get(server)
|
|
44
|
+
return entry if isinstance(entry, dict) else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save(server: str, credential: dict[str, Any]) -> Path:
|
|
48
|
+
"""Merge ``credential`` under ``server`` and persist. Dir ``0700``, file ``0600``."""
|
|
49
|
+
path = cache_path()
|
|
50
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
51
|
+
data = load()
|
|
52
|
+
data[server] = credential
|
|
53
|
+
# Write via a temp file then replace, so a crash mid-write can't truncate the cache. The temp
|
|
54
|
+
# file is created 0600 at open (not chmod'd after), so tokens never sit on disk umask-readable.
|
|
55
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
56
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
57
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
58
|
+
json.dump(data, fh, indent=2, default=str)
|
|
59
|
+
os.replace(tmp, path)
|
|
60
|
+
return path
|
kx_auth_cli/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The ``kx`` umbrella entry point (stdlib argparse).
|
|
2
|
+
|
|
3
|
+
The ``auth`` group exposes ``introspect``, ``login``, ``exchange``, and ``assert``; the umbrella
|
|
4
|
+
leaves room for future top-level ``kx`` commands. Each leaf command sets ``func``; ``main`` dispatches
|
|
5
|
+
and propagates its int return as the process exit code. Argparse's own usage errors exit ``2`` — the
|
|
6
|
+
code the spec reserves for that.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from . import assert_cmd, exchange, introspect, login
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="kx",
|
|
21
|
+
description="kx control-plane CLI. Agent-first: every command supports --json and a stable "
|
|
22
|
+
"exit-code contract (0 ok · 1 error · 2 usage · 3 auth-required · 4 denied).",
|
|
23
|
+
)
|
|
24
|
+
groups = parser.add_subparsers(dest="group", metavar="<group>")
|
|
25
|
+
|
|
26
|
+
auth = groups.add_parser(
|
|
27
|
+
"auth",
|
|
28
|
+
help="Authentication flows (introspect / login / exchange / assert).",
|
|
29
|
+
description="Authentication flows for the kx-mcp control plane: `introspect`, `login`, "
|
|
30
|
+
"`exchange`, `assert`.",
|
|
31
|
+
)
|
|
32
|
+
auth_commands = auth.add_subparsers(dest="auth_command", metavar="<command>")
|
|
33
|
+
|
|
34
|
+
introspect_parser = auth_commands.add_parser(
|
|
35
|
+
"introspect",
|
|
36
|
+
help="Validate a bearer token against the configured KX_MCP_AUTH keys.",
|
|
37
|
+
description=introspect.HELP_DESCRIPTION,
|
|
38
|
+
epilog=introspect.HELP_EPILOG,
|
|
39
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
40
|
+
)
|
|
41
|
+
introspect.add_arguments(introspect_parser)
|
|
42
|
+
introspect_parser.set_defaults(func=introspect.run)
|
|
43
|
+
|
|
44
|
+
login_parser = auth_commands.add_parser(
|
|
45
|
+
"login",
|
|
46
|
+
help="Acquire a bearer for an MCP server via server-advertised device-code OAuth.",
|
|
47
|
+
description=login.HELP_DESCRIPTION,
|
|
48
|
+
epilog=login.HELP_EPILOG,
|
|
49
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
50
|
+
)
|
|
51
|
+
login.add_arguments(login_parser)
|
|
52
|
+
login_parser.set_defaults(func=login.run)
|
|
53
|
+
|
|
54
|
+
exchange_parser = auth_commands.add_parser(
|
|
55
|
+
"exchange",
|
|
56
|
+
help="Swap a subject token for a backend-scoped credential (RFC 8693).",
|
|
57
|
+
description=exchange.HELP_DESCRIPTION,
|
|
58
|
+
epilog=exchange.HELP_EPILOG,
|
|
59
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
60
|
+
)
|
|
61
|
+
exchange.add_arguments(exchange_parser)
|
|
62
|
+
exchange_parser.set_defaults(func=exchange.run)
|
|
63
|
+
|
|
64
|
+
assert_parser = auth_commands.add_parser(
|
|
65
|
+
"assert",
|
|
66
|
+
help="Exercise the kdb+ identity-assertion handshake (project a principal; optionally bind it).",
|
|
67
|
+
description=assert_cmd.HELP_DESCRIPTION,
|
|
68
|
+
epilog=assert_cmd.HELP_EPILOG,
|
|
69
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
70
|
+
)
|
|
71
|
+
assert_cmd.add_arguments(assert_parser)
|
|
72
|
+
assert_parser.set_defaults(func=assert_cmd.run)
|
|
73
|
+
|
|
74
|
+
return parser
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main(argv: Optional[Sequence[str]] = None) -> None:
|
|
78
|
+
parser = build_parser()
|
|
79
|
+
args = parser.parse_args(argv)
|
|
80
|
+
func = getattr(args, "func", None)
|
|
81
|
+
if func is None:
|
|
82
|
+
# `kx` or `kx auth` with no leaf command — show help and exit as a usage error.
|
|
83
|
+
parser.print_help(sys.stderr)
|
|
84
|
+
sys.exit(2)
|
|
85
|
+
sys.exit(func(args))
|
kx_auth_cli/discovery.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
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 and `mcp-container-design.md`: the agent points at an
|
|
4
|
+
endpoint it already knows — **the MCP server (the container / resource server)** — and that endpoint
|
|
5
|
+
advertises its authorization server. So 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
|
+
container (the spec forbids the resource server 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 _prm_candidates(server: str) -> list[str]:
|
|
59
|
+
"""RFC 9728 well-known URLs for a resource: the path-aware form first, then the origin root."""
|
|
60
|
+
parts = urlsplit(server)
|
|
61
|
+
origin = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
|
62
|
+
root = origin + "/.well-known/oauth-protected-resource"
|
|
63
|
+
path = parts.path.strip("/")
|
|
64
|
+
return [f"{root}/{path}", root] if path else [root]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get_json(client: httpx.Client, url: str) -> dict[str, Any]:
|
|
68
|
+
resp = client.get(url, headers={"Accept": "application/json"})
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
try:
|
|
71
|
+
body = resp.json()
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise DiscoveryError(f"metadata at {url} was not valid JSON") from exc
|
|
74
|
+
if not isinstance(body, dict):
|
|
75
|
+
raise DiscoveryError(f"metadata at {url} was not a JSON object")
|
|
76
|
+
return body
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def discover(server: str, *, client: httpx.Client) -> AuthMetadata:
|
|
80
|
+
"""Resolve the AS endpoints for an MCP-server (resource) URL via RFC 9728 → RFC 8414/OIDC."""
|
|
81
|
+
prm: Optional[dict[str, Any]] = None
|
|
82
|
+
last_prm_exc: Optional[Exception] = None
|
|
83
|
+
for prm_url in _prm_candidates(server):
|
|
84
|
+
try:
|
|
85
|
+
candidate = _get_json(client, prm_url)
|
|
86
|
+
except (httpx.HTTPError, DiscoveryError) as exc:
|
|
87
|
+
last_prm_exc = exc
|
|
88
|
+
continue
|
|
89
|
+
if candidate.get("authorization_servers"):
|
|
90
|
+
prm = candidate
|
|
91
|
+
break
|
|
92
|
+
last_prm_exc = DiscoveryError(
|
|
93
|
+
f"{prm_url} advertised no authorization_servers — cannot discover an IdP"
|
|
94
|
+
)
|
|
95
|
+
if prm is None:
|
|
96
|
+
raise DiscoveryError(
|
|
97
|
+
f"could not discover protected-resource metadata for {server}: {last_prm_exc}"
|
|
98
|
+
)
|
|
99
|
+
as_url = prm["authorization_servers"][0]
|
|
100
|
+
|
|
101
|
+
# RFC 8414 and OIDC differ in well-known path; try both against the AS origin + issuer path.
|
|
102
|
+
candidates = [
|
|
103
|
+
urljoin(as_url.rstrip("/") + "/", ".well-known/oauth-authorization-server"),
|
|
104
|
+
urljoin(as_url.rstrip("/") + "/", ".well-known/openid-configuration"),
|
|
105
|
+
]
|
|
106
|
+
last_exc: Optional[Exception] = None
|
|
107
|
+
for url in candidates:
|
|
108
|
+
try:
|
|
109
|
+
meta = _get_json(client, url)
|
|
110
|
+
except (httpx.HTTPError, DiscoveryError) as exc:
|
|
111
|
+
last_exc = exc
|
|
112
|
+
continue
|
|
113
|
+
token_endpoint = meta.get("token_endpoint")
|
|
114
|
+
if not token_endpoint:
|
|
115
|
+
last_exc = DiscoveryError(f"{url} advertised no token_endpoint")
|
|
116
|
+
continue
|
|
117
|
+
return AuthMetadata(
|
|
118
|
+
authorization_server=as_url,
|
|
119
|
+
token_endpoint=token_endpoint,
|
|
120
|
+
device_authorization_endpoint=meta.get("device_authorization_endpoint"),
|
|
121
|
+
registration_endpoint=meta.get("registration_endpoint"),
|
|
122
|
+
)
|
|
123
|
+
raise DiscoveryError(
|
|
124
|
+
f"could not fetch authorization-server metadata from {as_url}: {last_exc}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def register_client(
|
|
129
|
+
meta: AuthMetadata, *, client: httpx.Client, client_name: str, scopes: Optional[list[str]] = None
|
|
130
|
+
) -> str:
|
|
131
|
+
"""RFC 7591 Dynamic Client Registration for the device-code flow → the issued ``client_id``."""
|
|
132
|
+
if not meta.registration_endpoint:
|
|
133
|
+
raise DiscoveryError(
|
|
134
|
+
"the authorization server advertises no registration_endpoint — pass --client-id "
|
|
135
|
+
"(or $KX_AUTH_CLIENT_ID) instead"
|
|
136
|
+
)
|
|
137
|
+
payload: dict[str, Any] = {
|
|
138
|
+
"client_name": client_name,
|
|
139
|
+
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code"],
|
|
140
|
+
"token_endpoint_auth_method": "none", # public client — device-code, no secret
|
|
141
|
+
}
|
|
142
|
+
if scopes:
|
|
143
|
+
payload["scope"] = " ".join(scopes)
|
|
144
|
+
try:
|
|
145
|
+
resp = client.post(meta.registration_endpoint, json=payload)
|
|
146
|
+
resp.raise_for_status()
|
|
147
|
+
body = resp.json()
|
|
148
|
+
except (httpx.HTTPError, ValueError) as exc: # ValueError: a non-JSON body
|
|
149
|
+
raise DiscoveryError(f"dynamic client registration failed: {exc}") from exc
|
|
150
|
+
client_id = body.get("client_id")
|
|
151
|
+
if not client_id:
|
|
152
|
+
raise DiscoveryError("registration endpoint returned no client_id")
|
|
153
|
+
return client_id
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def start_device_authorization(
|
|
157
|
+
meta: AuthMetadata, client_id: str, *, client: httpx.Client, scopes: Optional[list[str]] = None
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""RFC 8628 device-authorization request → the device/user codes + polling parameters."""
|
|
160
|
+
if not meta.device_authorization_endpoint:
|
|
161
|
+
raise DeviceAuthError(
|
|
162
|
+
"the authorization server advertises no device_authorization_endpoint — "
|
|
163
|
+
"device-code login is unavailable",
|
|
164
|
+
reason="device_flow_unsupported",
|
|
165
|
+
)
|
|
166
|
+
data = {"client_id": client_id}
|
|
167
|
+
if scopes:
|
|
168
|
+
data["scope"] = " ".join(scopes)
|
|
169
|
+
try:
|
|
170
|
+
resp = client.post(meta.device_authorization_endpoint, data=data)
|
|
171
|
+
resp.raise_for_status()
|
|
172
|
+
body = resp.json()
|
|
173
|
+
except (httpx.HTTPError, ValueError) as exc: # ValueError: a non-JSON body
|
|
174
|
+
raise DeviceAuthError(f"device-authorization request failed: {exc}") from exc
|
|
175
|
+
if not body.get("device_code") or not body.get("user_code"):
|
|
176
|
+
raise DeviceAuthError("device-authorization response missing device_code/user_code")
|
|
177
|
+
return body
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def poll_for_token(
|
|
181
|
+
meta: AuthMetadata,
|
|
182
|
+
client_id: str,
|
|
183
|
+
device_code: str,
|
|
184
|
+
*,
|
|
185
|
+
client: httpx.Client,
|
|
186
|
+
interval: int = 5,
|
|
187
|
+
expires_in: int = 600,
|
|
188
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
189
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
"""RFC 8628 polling: hit the token endpoint until success, denial, or expiry.
|
|
192
|
+
|
|
193
|
+
``sleep`` / ``monotonic`` are injectable so tests drive the loop without real waits.
|
|
194
|
+
"""
|
|
195
|
+
deadline = monotonic() + expires_in
|
|
196
|
+
wait = max(1, interval)
|
|
197
|
+
while True:
|
|
198
|
+
resp = client.post(
|
|
199
|
+
meta.token_endpoint,
|
|
200
|
+
data={
|
|
201
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
202
|
+
"device_code": device_code,
|
|
203
|
+
"client_id": client_id,
|
|
204
|
+
},
|
|
205
|
+
)
|
|
206
|
+
try:
|
|
207
|
+
body: dict[str, Any] = resp.json() if resp.content else {}
|
|
208
|
+
except ValueError:
|
|
209
|
+
# A non-JSON body (e.g. a proxy's HTML error page) → fall through to the status-code
|
|
210
|
+
# error below rather than crashing out of the exit-code contract.
|
|
211
|
+
body = {}
|
|
212
|
+
if resp.status_code == 200 and body.get("access_token"):
|
|
213
|
+
return body
|
|
214
|
+
|
|
215
|
+
error = body.get("error", "")
|
|
216
|
+
if error == "authorization_pending":
|
|
217
|
+
pass
|
|
218
|
+
elif error == "slow_down":
|
|
219
|
+
wait += 5
|
|
220
|
+
elif error == "access_denied":
|
|
221
|
+
raise DeviceAuthError("the user denied the authorization request", reason="access_denied")
|
|
222
|
+
elif error == "expired_token":
|
|
223
|
+
raise DeviceAuthError("the device code expired before approval", reason="expired_token")
|
|
224
|
+
else:
|
|
225
|
+
raise DeviceAuthError(
|
|
226
|
+
f"token endpoint returned an unexpected error: {error or resp.status_code}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if monotonic() >= deadline:
|
|
230
|
+
raise DeviceAuthError("timed out waiting for device-code approval", reason="timeout")
|
|
231
|
+
sleep(wait)
|