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