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