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/exchange.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
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 the
|
|
4
|
+
container'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 (first found wins): ``--subject`` → ``$KX_AUTH_TOKEN`` → stdin → the cached
|
|
10
|
+
``login`` token for ``--server`` (the seamless ``login`` → ``exchange`` chain). ``service_account``
|
|
11
|
+
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 json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
from kx_auth_core import OutboundConfig, exchange
|
|
26
|
+
|
|
27
|
+
from . import cache
|
|
28
|
+
|
|
29
|
+
EXIT_OK = 0
|
|
30
|
+
EXIT_ERROR = 1
|
|
31
|
+
EXIT_USAGE = 2
|
|
32
|
+
EXIT_AUTH_REQUIRED = 3
|
|
33
|
+
EXIT_DENIED = 4
|
|
34
|
+
|
|
35
|
+
_NEEDS_SUBJECT = {"passthrough", "rfc_8693"}
|
|
36
|
+
|
|
37
|
+
HELP_DESCRIPTION = (
|
|
38
|
+
"Exchange a subject token for a backend-scoped credential via the shared outbound seam "
|
|
39
|
+
"(the same code path the container's backends use). Default strategy: rfc_8693."
|
|
40
|
+
)
|
|
41
|
+
HELP_EPILOG = """\
|
|
42
|
+
exit codes (branch on the code, not the text):
|
|
43
|
+
0 ok a credential was minted/forwarded
|
|
44
|
+
1 error misconfig, unreachable endpoint, or the endpoint returned no token
|
|
45
|
+
2 usage bad flags, or no subject token for a strategy that needs one
|
|
46
|
+
3 auth-required the cached login for --server has expired — `kx auth login` again
|
|
47
|
+
4 denied the strategy refused (e.g. passthrough audience-guard mismatch)
|
|
48
|
+
|
|
49
|
+
subject source (first found wins): --subject -> $KX_AUTH_TOKEN -> stdin -> login cache (--server)
|
|
50
|
+
|
|
51
|
+
strategies:
|
|
52
|
+
rfc_8693 (default) token exchange at --token-url for --audience; needs a subject token
|
|
53
|
+
passthrough forward the subject unchanged iff its aud already includes --audience
|
|
54
|
+
service_account client_credentials at --token-url (the container's own identity; no subject)
|
|
55
|
+
|
|
56
|
+
examples:
|
|
57
|
+
# RFC 8693 swap (subject as a flag), structured output
|
|
58
|
+
kx auth exchange --subject "$TOK" --audience kdbai \\
|
|
59
|
+
--token-url https://idp/token --client-id mcp-container --client-secret "$SECRET" --json
|
|
60
|
+
|
|
61
|
+
# chain off a prior `kx auth login` — subject pulled from the cache for that server
|
|
62
|
+
kx auth login --server https://mcp.example --json
|
|
63
|
+
kx auth exchange --server https://mcp.example --audience backend-api --token-url https://idp/token --json
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
68
|
+
parser.add_argument("--subject", help="The subject (inbound) token. Falls back to $KX_AUTH_TOKEN, stdin, then the login cache.")
|
|
69
|
+
parser.add_argument("--audience", help="Target backend audience (the token is scoped to this).")
|
|
70
|
+
parser.add_argument("--strategy", default="rfc_8693", help="Outbound strategy (default rfc_8693).")
|
|
71
|
+
parser.add_argument("--token-url", help="OIDC/STS token endpoint (rfc_8693 / service_account).")
|
|
72
|
+
parser.add_argument("--resource", help="RFC 8707 resource URI — an alternative to --audience.")
|
|
73
|
+
parser.add_argument("--client-id", help="Client id for authenticating at the token endpoint.")
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--client-secret",
|
|
76
|
+
default=os.environ.get("KX_AUTH_CLIENT_SECRET"),
|
|
77
|
+
help="Client secret paired with --client-id. Falls back to $KX_AUTH_CLIENT_SECRET "
|
|
78
|
+
"(preferred — keeps the secret out of shell history and process listings).",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--client-auth", choices=("post", "basic"), default="post",
|
|
82
|
+
help="How client credentials reach the token endpoint (default post).",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--scope", action="append", metavar="SCOPE",
|
|
86
|
+
help="Requested scope; repeatable, or comma/space-separated within one value.",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument("--server", help="MCP-server URL — used to pull the subject from the login cache.")
|
|
89
|
+
parser.add_argument("--no-verify", action="store_true", help="Disable TLS verification (dev IdPs).")
|
|
90
|
+
parser.add_argument("--timeout", type=float, default=30.0, help="Token-request timeout, seconds.")
|
|
91
|
+
parser.add_argument("--json", action="store_true", help="Emit a structured JSON envelope (the agent path).")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_subject(args: argparse.Namespace) -> tuple[str | None, bool]:
|
|
95
|
+
"""Resolve the subject token; the second element flags that the only candidate was an
|
|
96
|
+
expired ``login``-cache entry (so the caller can map it to auth-required, not usage)."""
|
|
97
|
+
if args.subject:
|
|
98
|
+
return args.subject, False
|
|
99
|
+
env = os.environ.get("KX_AUTH_TOKEN")
|
|
100
|
+
if env:
|
|
101
|
+
return env.strip(), False
|
|
102
|
+
try:
|
|
103
|
+
if not sys.stdin.isatty():
|
|
104
|
+
piped = sys.stdin.read().strip()
|
|
105
|
+
if piped:
|
|
106
|
+
return piped, False
|
|
107
|
+
except (OSError, ValueError):
|
|
108
|
+
pass
|
|
109
|
+
if args.server:
|
|
110
|
+
entry = cache.get(args.server)
|
|
111
|
+
if entry and entry.get("access_token"):
|
|
112
|
+
expires_at = entry.get("expires_at")
|
|
113
|
+
if expires_at and time.time() >= float(expires_at):
|
|
114
|
+
return None, True
|
|
115
|
+
return entry["access_token"], False
|
|
116
|
+
return None, False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _scopes(args: argparse.Namespace) -> list[str] | None:
|
|
120
|
+
if not args.scope:
|
|
121
|
+
return None
|
|
122
|
+
out: list[str] = []
|
|
123
|
+
for chunk in args.scope:
|
|
124
|
+
out.extend(part for part in chunk.replace(",", " ").split() if part)
|
|
125
|
+
return out or None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _config_from_args(args: argparse.Namespace) -> OutboundConfig:
|
|
129
|
+
return OutboundConfig(
|
|
130
|
+
strategy=args.strategy,
|
|
131
|
+
token_url=args.token_url,
|
|
132
|
+
audience=args.audience,
|
|
133
|
+
resource=args.resource,
|
|
134
|
+
client_id=args.client_id,
|
|
135
|
+
client_secret=args.client_secret,
|
|
136
|
+
client_auth=args.client_auth,
|
|
137
|
+
scopes=_scopes(args),
|
|
138
|
+
verify=not args.no_verify,
|
|
139
|
+
timeout=args.timeout,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _emit(args: argparse.Namespace, *, status: str, reason: str | None = None, credential=None) -> None:
|
|
144
|
+
if args.json:
|
|
145
|
+
if credential is not None:
|
|
146
|
+
envelope = {
|
|
147
|
+
"status": status,
|
|
148
|
+
"access_token": credential.access_token,
|
|
149
|
+
"token_type": credential.token_type,
|
|
150
|
+
"expires_in": credential.expires_in,
|
|
151
|
+
"strategy": credential.strategy,
|
|
152
|
+
"claims": credential.claims,
|
|
153
|
+
}
|
|
154
|
+
else:
|
|
155
|
+
envelope = {"status": status, "reason": reason}
|
|
156
|
+
print(json.dumps(envelope, indent=2, default=str))
|
|
157
|
+
return
|
|
158
|
+
if credential is not None:
|
|
159
|
+
print(f"ok — strategy={credential.strategy} token_type={credential.token_type} access_token={credential.access_token}")
|
|
160
|
+
else:
|
|
161
|
+
print(f"{status}: {reason}", file=sys.stderr)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def run(args: argparse.Namespace) -> int:
|
|
165
|
+
subject, cache_expired = _resolve_subject(args)
|
|
166
|
+
if args.strategy in _NEEDS_SUBJECT and not subject:
|
|
167
|
+
if cache_expired:
|
|
168
|
+
_emit(
|
|
169
|
+
args,
|
|
170
|
+
status="auth_required",
|
|
171
|
+
reason=f"the cached login for {args.server} has expired — "
|
|
172
|
+
f"run `kx auth login --server {args.server}` again",
|
|
173
|
+
)
|
|
174
|
+
return EXIT_AUTH_REQUIRED
|
|
175
|
+
print(
|
|
176
|
+
f"error: strategy '{args.strategy}' needs a subject token "
|
|
177
|
+
"(pass --subject, $KX_AUTH_TOKEN, stdin, or --server for the login cache)",
|
|
178
|
+
file=sys.stderr,
|
|
179
|
+
)
|
|
180
|
+
return EXIT_USAGE
|
|
181
|
+
|
|
182
|
+
config = _config_from_args(args)
|
|
183
|
+
try:
|
|
184
|
+
credential = asyncio.run(exchange(config, subject))
|
|
185
|
+
except PermissionError as exc: # strategy refusal (e.g. passthrough audience guard)
|
|
186
|
+
_emit(args, status="denied", reason=str(exc))
|
|
187
|
+
return EXIT_DENIED
|
|
188
|
+
except Exception as exc: # ValueError (misconfig / no token) / httpx / unexpected
|
|
189
|
+
_emit(args, status="error", reason=str(exc))
|
|
190
|
+
return EXIT_ERROR
|
|
191
|
+
|
|
192
|
+
_emit(args, status="ok", credential=credential)
|
|
193
|
+
return EXIT_OK
|
|
@@ -0,0 +1,167 @@
|
|
|
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 the container enforces) and
|
|
4
|
+
maps its categorised verdict to the stable exit-code contract:
|
|
5
|
+
|
|
6
|
+
* ``0`` valid · ``1`` error (malformed / bad signature / unreachable JWKS) · ``2`` usage (no token) ·
|
|
7
|
+
``3`` auth-required (expired — the agent should ``login``) · ``4`` denied (issuer / audience /
|
|
8
|
+
required-scope mismatch).
|
|
9
|
+
|
|
10
|
+
Config defaults come from the ``KX_MCP_AUTH*`` environment (so the CLI checks against the same keys
|
|
11
|
+
the container uses); the flags below override per-invocation. ``introspect`` is stateless — it does
|
|
12
|
+
not touch the token cache. (Server-advertised RFC 9728 discovery is handled by ``login``.)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
from kx_auth_core import (
|
|
23
|
+
AUTH_REQUIRED,
|
|
24
|
+
DENIED,
|
|
25
|
+
ERROR,
|
|
26
|
+
OK,
|
|
27
|
+
AuthSettings,
|
|
28
|
+
VerifyResult,
|
|
29
|
+
verify_token,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
EXIT_CODES = {OK: 0, ERROR: 1, AUTH_REQUIRED: 3, DENIED: 4}
|
|
33
|
+
|
|
34
|
+
# CLI help kept separate from the module docstring above: the docstring is RST for code/IDE readers;
|
|
35
|
+
# this is clean plaintext for the terminal (and for an agent reading `--help`). RawDescriptionHelpFormatter
|
|
36
|
+
# prints the epilog verbatim, so the alignment below is preserved.
|
|
37
|
+
HELP_DESCRIPTION = (
|
|
38
|
+
"Validate a bearer token against the same keys the container enforces and report a "
|
|
39
|
+
"machine-readable verdict. Stateless — does not touch any token cache."
|
|
40
|
+
)
|
|
41
|
+
HELP_EPILOG = """\
|
|
42
|
+
exit codes (branch on the code, not the text):
|
|
43
|
+
0 ok valid
|
|
44
|
+
1 error malformed / bad signature / unreachable JWKS
|
|
45
|
+
2 usage bad flags, or no token supplied
|
|
46
|
+
3 auth-required well-signed but expired — re-authenticate (kx auth login)
|
|
47
|
+
4 denied well-signed but wrong issuer / audience / required scope
|
|
48
|
+
|
|
49
|
+
token source (first found wins): argument -> $KX_AUTH_TOKEN -> stdin
|
|
50
|
+
|
|
51
|
+
config comes from the environment (the same vars the container reads); flags override per call:
|
|
52
|
+
--public-key-path KX_MCP_AUTH_PUBLIC_KEY_PATH static mode (PEM on disk)
|
|
53
|
+
--public-key KX_MCP_AUTH_PUBLIC_KEY static mode (inline PEM)
|
|
54
|
+
--jwks-uri KX_MCP_AUTH_JWKS_URI jwks mode (remote endpoint)
|
|
55
|
+
--issuer KX_MCP_AUTH_ISSUER
|
|
56
|
+
--audience KX_MCP_AUTH_AUDIENCE
|
|
57
|
+
--algorithm KX_MCP_AUTH_ALGORITHM default RS256
|
|
58
|
+
--required-scopes KX_MCP_AUTH_REQUIRED_SCOPES comma/space separated
|
|
59
|
+
|
|
60
|
+
examples:
|
|
61
|
+
# token + key as flags, structured verdict
|
|
62
|
+
kx auth introspect "$TOKEN" --public-key-path key.pub --issuer https://idp --audience my-api --json
|
|
63
|
+
|
|
64
|
+
# config + token entirely from the environment (matches the container)
|
|
65
|
+
KX_MCP_AUTH=static KX_MCP_AUTH_PUBLIC_KEY_PATH=key.pub \\
|
|
66
|
+
KX_AUTH_TOKEN="$TOKEN" kx auth introspect --json
|
|
67
|
+
|
|
68
|
+
# validate against a live JWKS endpoint, token piped on stdin
|
|
69
|
+
echo "$TOKEN" | kx auth introspect --jwks-uri https://idp/.well-known/jwks.json
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"token",
|
|
76
|
+
nargs="?",
|
|
77
|
+
help="The bearer token to validate. Falls back to $KX_AUTH_TOKEN, then stdin.",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--json",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="Emit a structured JSON envelope (status/valid/claims/reason) — the agent path.",
|
|
83
|
+
)
|
|
84
|
+
# Verification config — overrides the KX_MCP_AUTH* environment for this invocation.
|
|
85
|
+
parser.add_argument("--jwks-uri", help="Remote JWKS endpoint (selects jwks mode).")
|
|
86
|
+
parser.add_argument("--public-key", help="Inline RS256 public-key PEM (selects static mode).")
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--public-key-path", help="Path to an RS256 public-key PEM (selects static mode)."
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument("--issuer", help="Expected `iss` claim.")
|
|
91
|
+
parser.add_argument("--audience", help="Expected `aud` claim.")
|
|
92
|
+
parser.add_argument("--algorithm", help="Signing algorithm to accept (default RS256).")
|
|
93
|
+
parser.add_argument(
|
|
94
|
+
"--required-scopes", help="Comma/space-separated scopes that must be present."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_token(args: argparse.Namespace) -> str | None:
|
|
99
|
+
if args.token:
|
|
100
|
+
return args.token
|
|
101
|
+
env = os.environ.get("KX_AUTH_TOKEN")
|
|
102
|
+
if env:
|
|
103
|
+
return env.strip()
|
|
104
|
+
try:
|
|
105
|
+
if not sys.stdin.isatty():
|
|
106
|
+
piped = sys.stdin.read().strip()
|
|
107
|
+
if piped:
|
|
108
|
+
return piped
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
# stdin unavailable (e.g. captured under a test harness) — treat as "no token".
|
|
111
|
+
pass
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _settings_from_args(args: argparse.Namespace) -> AuthSettings:
|
|
116
|
+
"""Start from the KX_MCP_AUTH* environment, then apply explicit flag overrides. Key material on
|
|
117
|
+
the CLI selects the mode, so `introspect <token> --public-key-path k.pub` works with no env."""
|
|
118
|
+
overrides: dict = {}
|
|
119
|
+
for flag in ("jwks_uri", "public_key", "public_key_path", "issuer", "audience"):
|
|
120
|
+
value = getattr(args, flag)
|
|
121
|
+
if value is not None:
|
|
122
|
+
overrides[flag] = value
|
|
123
|
+
if args.algorithm:
|
|
124
|
+
overrides["algorithm"] = args.algorithm
|
|
125
|
+
if args.required_scopes:
|
|
126
|
+
overrides["required_scopes"] = args.required_scopes
|
|
127
|
+
if args.public_key or args.public_key_path:
|
|
128
|
+
overrides["mode"] = "static"
|
|
129
|
+
elif args.jwks_uri:
|
|
130
|
+
overrides["mode"] = "jwks"
|
|
131
|
+
return AuthSettings(**overrides)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _render(result: VerifyResult, *, json_output: bool) -> None:
|
|
135
|
+
if json_output:
|
|
136
|
+
envelope: dict = {"status": result.category, "valid": result.valid}
|
|
137
|
+
if result.valid:
|
|
138
|
+
envelope["client_id"] = result.client_id
|
|
139
|
+
envelope["scopes"] = result.scopes
|
|
140
|
+
envelope["claims"] = result.claims
|
|
141
|
+
else:
|
|
142
|
+
envelope["reason"] = result.reason
|
|
143
|
+
print(json.dumps(envelope, indent=2, default=str))
|
|
144
|
+
return
|
|
145
|
+
if result.valid:
|
|
146
|
+
print(f"valid — client_id={result.client_id} scopes={result.scopes or []}")
|
|
147
|
+
else:
|
|
148
|
+
print(f"{result.category}: {result.reason}", file=sys.stderr)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def run(args: argparse.Namespace) -> int:
|
|
152
|
+
token = _resolve_token(args)
|
|
153
|
+
if not token:
|
|
154
|
+
print(
|
|
155
|
+
"error: no token provided (pass as an argument, $KX_AUTH_TOKEN, or via stdin)",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
return 2 # usage error — consistent with argparse's own exit 2
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
settings = _settings_from_args(args)
|
|
162
|
+
result = verify_token(token, settings)
|
|
163
|
+
except Exception as exc: # config / unexpected failure → error
|
|
164
|
+
result = VerifyResult.fail(ERROR, str(exc))
|
|
165
|
+
|
|
166
|
+
_render(result, json_output=args.json)
|
|
167
|
+
return EXIT_CODES[result.category]
|
kx_auth_cli/login.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
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 container / resource server)** you connect to — *not* a backend. The container publishes RFC 9728
|
|
5
|
+
metadata naming its authorization server; ``kx auth`` discovers the AS and runs the **RFC 8628
|
|
6
|
+
device-code** flow directly against it (never redirecting through the container). The token is
|
|
7
|
+
audienced to 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 json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from . import cache, discovery
|
|
26
|
+
from .discovery import DeviceAuthError, DiscoveryError
|
|
27
|
+
|
|
28
|
+
EXIT_OK = 0
|
|
29
|
+
EXIT_ERROR = 1
|
|
30
|
+
EXIT_USAGE = 2
|
|
31
|
+
EXIT_AUTH_REQUIRED = 3
|
|
32
|
+
EXIT_DENIED = 4
|
|
33
|
+
|
|
34
|
+
# DeviceAuthError.reason → exit code (anything unlisted is a plain error).
|
|
35
|
+
_REASON_EXIT = {
|
|
36
|
+
"access_denied": EXIT_DENIED,
|
|
37
|
+
"expired_token": EXIT_AUTH_REQUIRED,
|
|
38
|
+
"timeout": EXIT_AUTH_REQUIRED,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
HELP_DESCRIPTION = (
|
|
42
|
+
"Acquire a bearer for an MCP server via the device-code flow against the authorization server "
|
|
43
|
+
"the server advertises (RFC 9728 discovery → RFC 8628). Caches the token for `exchange`."
|
|
44
|
+
)
|
|
45
|
+
HELP_EPILOG = """\
|
|
46
|
+
exit codes (branch on the code, not the text):
|
|
47
|
+
0 ok a token was acquired and cached
|
|
48
|
+
1 error discovery failed, unreachable endpoint, no device-code support, or DCR failure
|
|
49
|
+
2 usage bad flags
|
|
50
|
+
3 auth-required the device code expired / timed out before approval — retry
|
|
51
|
+
4 denied the user denied the authorization request
|
|
52
|
+
|
|
53
|
+
--server is the MCP-server (resource) URL you connect to — NOT a backend. The client talks directly
|
|
54
|
+
to the discovered authorization server; the token is audienced to that resource. Use `kx auth
|
|
55
|
+
exchange` for backend-scoped tokens.
|
|
56
|
+
|
|
57
|
+
client identity (DCR-first): RFC 7591 dynamic registration if the AS supports it, else --client-id
|
|
58
|
+
(or $KX_AUTH_CLIENT_ID).
|
|
59
|
+
|
|
60
|
+
examples:
|
|
61
|
+
kx auth login --server https://mcp.example --json
|
|
62
|
+
kx auth login --server https://mcp.example --client-id my-public-client --scope "kdbx.read offline_access"
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
67
|
+
parser.add_argument("--server", required=True, help="MCP-server (resource) URL to authenticate to.")
|
|
68
|
+
parser.add_argument("--client-id", help="OAuth client id (overrides DCR; falls back to $KX_AUTH_CLIENT_ID).")
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--scope", action="append", metavar="SCOPE",
|
|
71
|
+
help="Requested scope; repeatable, or comma/space-separated within one value.",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument("--no-verify", action="store_true", help="Disable TLS verification (dev IdPs).")
|
|
74
|
+
parser.add_argument("--timeout", type=float, default=30.0, help="Per-request timeout, seconds.")
|
|
75
|
+
parser.add_argument("--json", action="store_true", help="Emit a structured JSON envelope (the agent path).")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _scopes(args: argparse.Namespace) -> list[str] | None:
|
|
79
|
+
if not args.scope:
|
|
80
|
+
return None
|
|
81
|
+
out: list[str] = []
|
|
82
|
+
for chunk in args.scope:
|
|
83
|
+
out.extend(part for part in chunk.replace(",", " ").split() if part)
|
|
84
|
+
return out or None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _prompt(device: dict) -> None:
|
|
88
|
+
"""Show the user where to approve — to stderr, so a --json stdout stays clean."""
|
|
89
|
+
complete = device.get("verification_uri_complete")
|
|
90
|
+
uri = device.get("verification_uri", "<unknown>")
|
|
91
|
+
code = device.get("user_code", "<unknown>")
|
|
92
|
+
if complete:
|
|
93
|
+
print(f"To authorize, open: {complete}", file=sys.stderr)
|
|
94
|
+
print(f"To authorize, visit {uri} and enter code: {code}", file=sys.stderr)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _emit_ok(args: argparse.Namespace, meta, token: dict, cache_path) -> None:
|
|
98
|
+
if args.json:
|
|
99
|
+
print(json.dumps(
|
|
100
|
+
{
|
|
101
|
+
"status": "ok",
|
|
102
|
+
"server": args.server,
|
|
103
|
+
"authorization_server": meta.authorization_server,
|
|
104
|
+
"access_token": token["access_token"],
|
|
105
|
+
"token_type": token.get("token_type", "Bearer"),
|
|
106
|
+
"expires_in": token.get("expires_in"),
|
|
107
|
+
"cached": True,
|
|
108
|
+
"cache_path": str(cache_path),
|
|
109
|
+
},
|
|
110
|
+
indent=2,
|
|
111
|
+
default=str,
|
|
112
|
+
))
|
|
113
|
+
return
|
|
114
|
+
print(f"ok — logged in to {args.server} (cached at {cache_path})")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _emit_fail(args: argparse.Namespace, status: str, reason: str) -> None:
|
|
118
|
+
if args.json:
|
|
119
|
+
print(json.dumps({"status": status, "reason": reason}, indent=2, default=str))
|
|
120
|
+
else:
|
|
121
|
+
print(f"{status}: {reason}", file=sys.stderr)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run(args: argparse.Namespace) -> int:
|
|
125
|
+
scopes = _scopes(args)
|
|
126
|
+
client_id = args.client_id or os.environ.get("KX_AUTH_CLIENT_ID")
|
|
127
|
+
try:
|
|
128
|
+
with httpx.Client(verify=not args.no_verify, timeout=args.timeout) as client:
|
|
129
|
+
meta = discovery.discover(args.server, client=client)
|
|
130
|
+
if not client_id:
|
|
131
|
+
client_id = discovery.register_client(
|
|
132
|
+
meta, client=client, client_name="kx-auth-cli", scopes=scopes
|
|
133
|
+
)
|
|
134
|
+
device = discovery.start_device_authorization(meta, client_id, client=client, scopes=scopes)
|
|
135
|
+
_prompt(device)
|
|
136
|
+
token = discovery.poll_for_token(
|
|
137
|
+
meta,
|
|
138
|
+
client_id,
|
|
139
|
+
device["device_code"],
|
|
140
|
+
client=client,
|
|
141
|
+
interval=int(device.get("interval", 5)),
|
|
142
|
+
expires_in=int(device.get("expires_in", 600)),
|
|
143
|
+
)
|
|
144
|
+
except DiscoveryError as exc:
|
|
145
|
+
_emit_fail(args, "error", str(exc))
|
|
146
|
+
return EXIT_ERROR
|
|
147
|
+
except DeviceAuthError as exc:
|
|
148
|
+
status = "denied" if exc.reason == "access_denied" else (
|
|
149
|
+
"auth_required" if _REASON_EXIT.get(exc.reason) == EXIT_AUTH_REQUIRED else "error"
|
|
150
|
+
)
|
|
151
|
+
_emit_fail(args, status, str(exc))
|
|
152
|
+
return _REASON_EXIT.get(exc.reason, EXIT_ERROR)
|
|
153
|
+
except httpx.HTTPError as exc:
|
|
154
|
+
_emit_fail(args, "error", f"network error talking to the authorization server: {exc}")
|
|
155
|
+
return EXIT_ERROR
|
|
156
|
+
|
|
157
|
+
expires_in = token.get("expires_in")
|
|
158
|
+
now = int(time.time())
|
|
159
|
+
credential = {
|
|
160
|
+
"access_token": token["access_token"],
|
|
161
|
+
"refresh_token": token.get("refresh_token"),
|
|
162
|
+
"token_type": token.get("token_type", "Bearer"),
|
|
163
|
+
"scope": token.get("scope"),
|
|
164
|
+
"expires_at": now + int(expires_in) if expires_in else None,
|
|
165
|
+
"issued_at": now,
|
|
166
|
+
"authorization_server": meta.authorization_server,
|
|
167
|
+
}
|
|
168
|
+
cache_path = cache.save(args.server, credential)
|
|
169
|
+
_emit_ok(args, meta, token, cache_path)
|
|
170
|
+
return EXIT_OK
|