insightfactory-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- if_cli/__init__.py +1 -0
- if_cli/__main__.py +4 -0
- if_cli/cache.py +206 -0
- if_cli/cli.py +163 -0
- if_cli/colour.py +24 -0
- if_cli/commands/__init__.py +0 -0
- if_cli/commands/api.py +177 -0
- if_cli/commands/config.py +45 -0
- if_cli/commands/login.py +75 -0
- if_cli/commands/logout.py +20 -0
- if_cli/commands/profiles.py +119 -0
- if_cli/commands/set_token.py +62 -0
- if_cli/commands/token.py +32 -0
- if_cli/config.py +188 -0
- if_cli/constants.py +5 -0
- if_cli/http.py +153 -0
- if_cli/main.py +76 -0
- if_cli/oauth.py +389 -0
- if_cli/runtime.py +94 -0
- insightfactory_cli-1.0.0.dist-info/METADATA +265 -0
- insightfactory_cli-1.0.0.dist-info/RECORD +23 -0
- insightfactory_cli-1.0.0.dist-info/WHEEL +4 -0
- insightfactory_cli-1.0.0.dist-info/entry_points.txt +2 -0
if_cli/main.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from if_cli.cli import run_with_handlers
|
|
6
|
+
from if_cli.commands.api import api_command
|
|
7
|
+
from if_cli.commands.config import config_command
|
|
8
|
+
from if_cli.commands.login import login_command
|
|
9
|
+
from if_cli.commands.logout import logout_command
|
|
10
|
+
from if_cli.commands.profiles import profiles_command
|
|
11
|
+
from if_cli.commands.set_token import set_token_command
|
|
12
|
+
from if_cli.commands.token import token_command
|
|
13
|
+
from if_cli.constants import ENV_PROFILE
|
|
14
|
+
from if_cli.runtime import die
|
|
15
|
+
|
|
16
|
+
USAGE = f"""usage: if-cli <command> [options]
|
|
17
|
+
|
|
18
|
+
commands:
|
|
19
|
+
login browser OAuth login (auth-code + PKCE via the factory /authorize proxy)
|
|
20
|
+
[-p profile] [--host url] [--client-id id] [--organization org]
|
|
21
|
+
[--audience aud] [--callback-port n] [--no-browser]
|
|
22
|
+
token print a valid access token (auto-refreshes)
|
|
23
|
+
[-p profile] [--env] [--env-name VARIABLE]
|
|
24
|
+
logout clear the cached token for a profile [-p profile]
|
|
25
|
+
set-token store a manually copied token for a profile (fallback) [-p profile]
|
|
26
|
+
profiles list profiles and token status [--json]
|
|
27
|
+
config print a non-secret profile value
|
|
28
|
+
get <host|audience|callback_port|client_id|organization> [-p profile]
|
|
29
|
+
api call or discover factory API routes
|
|
30
|
+
[-p profile] [-X METHOD] [-d DATA] <path>
|
|
31
|
+
routes [-p profile] [filter]
|
|
32
|
+
describe [-p profile] METHOD <path>
|
|
33
|
+
|
|
34
|
+
profile selection: -p flag, then ${ENV_PROFILE}, then [DEFAULT].
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run_cli(argv: list[str]) -> None:
|
|
39
|
+
command = argv[0] if argv else None
|
|
40
|
+
rest = argv[1:]
|
|
41
|
+
if command == "login":
|
|
42
|
+
login_command(rest)
|
|
43
|
+
return
|
|
44
|
+
if command == "token":
|
|
45
|
+
token_command(rest)
|
|
46
|
+
return
|
|
47
|
+
if command == "logout":
|
|
48
|
+
logout_command(rest)
|
|
49
|
+
return
|
|
50
|
+
if command == "set-token":
|
|
51
|
+
set_token_command(rest)
|
|
52
|
+
return
|
|
53
|
+
if command == "profiles":
|
|
54
|
+
profiles_command(rest)
|
|
55
|
+
return
|
|
56
|
+
if command == "config":
|
|
57
|
+
config_command(rest)
|
|
58
|
+
return
|
|
59
|
+
if command == "api":
|
|
60
|
+
api_command(rest)
|
|
61
|
+
return
|
|
62
|
+
if command in {None, "-h", "--help"}:
|
|
63
|
+
import sys
|
|
64
|
+
|
|
65
|
+
sys.stdout.write(USAGE)
|
|
66
|
+
return
|
|
67
|
+
if command in {"-v", "--version"}:
|
|
68
|
+
import sys
|
|
69
|
+
|
|
70
|
+
sys.stdout.write(f"{version('insightfactory-cli')}\n")
|
|
71
|
+
return
|
|
72
|
+
die(f"unknown command '{command}'\n\n{USAGE}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str] | None = None) -> None:
|
|
76
|
+
run_with_handlers(run_cli, argv)
|
if_cli/oauth.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import secrets
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
13
|
+
|
|
14
|
+
from if_cli.cache import (
|
|
15
|
+
CacheEntry,
|
|
16
|
+
clear_refresh_token_if_matches,
|
|
17
|
+
is_fresh,
|
|
18
|
+
load_cache,
|
|
19
|
+
store_tokens,
|
|
20
|
+
)
|
|
21
|
+
from if_cli.config import Profile, meaningful
|
|
22
|
+
from if_cli.constants import DEFAULT_SCOPE, LOOPBACK_HOST
|
|
23
|
+
from if_cli.http import fetch_with_timeout, validate_http_url
|
|
24
|
+
from if_cli.runtime import CliError, die, escape_html, is_record
|
|
25
|
+
|
|
26
|
+
CALLBACK_TIMEOUT_SECONDS = 300
|
|
27
|
+
RECOVERY_POLL_ATTEMPTS = 10
|
|
28
|
+
RECOVERY_POLL_INTERVAL_MS = 40
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def base64url(data: bytes) -> str:
|
|
32
|
+
return base64.b64encode(data).decode("ascii").replace("+", "-").replace("/", "_").rstrip("=")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_pkce_challenge(verifier: str) -> str:
|
|
36
|
+
return base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def open_browser(url: str, launcher: str | None = None) -> None:
|
|
40
|
+
"""Open ``url`` with the platform browser launcher.
|
|
41
|
+
|
|
42
|
+
A missing desktop launcher must not terminate a headless login: the
|
|
43
|
+
authorization URL is always printed as a fallback.
|
|
44
|
+
"""
|
|
45
|
+
if launcher is not None:
|
|
46
|
+
command, args = launcher, [url]
|
|
47
|
+
elif sys.platform == "darwin":
|
|
48
|
+
command, args = "open", [url]
|
|
49
|
+
elif sys.platform == "win32":
|
|
50
|
+
command, args = "rundll32", ["url.dll,FileProtocolHandler", url]
|
|
51
|
+
else:
|
|
52
|
+
command, args = "xdg-open", [url]
|
|
53
|
+
try:
|
|
54
|
+
subprocess.Popen(
|
|
55
|
+
[command, *args],
|
|
56
|
+
stdout=subprocess.DEVNULL,
|
|
57
|
+
stderr=subprocess.DEVNULL,
|
|
58
|
+
start_new_session=True,
|
|
59
|
+
)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_callback_url(port: int) -> str:
|
|
65
|
+
return f"http://{LOOPBACK_HOST}:{port}/callback"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _discover(host: str) -> dict[str, Any]:
|
|
69
|
+
response = fetch_with_timeout(f"{host}/.well-known/oauth-authorization-server")
|
|
70
|
+
if not response.ok:
|
|
71
|
+
die(f"OAuth discovery failed for {host} (HTTP {response.status})")
|
|
72
|
+
try:
|
|
73
|
+
body: object = response.json()
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
die(f"OAuth discovery for {host} did not return valid JSON")
|
|
76
|
+
if not is_record(body):
|
|
77
|
+
die(f"OAuth discovery for {host} did not return a JSON object")
|
|
78
|
+
authorization_endpoint = body.get("authorization_endpoint")
|
|
79
|
+
token_endpoint = body.get("token_endpoint")
|
|
80
|
+
if not isinstance(authorization_endpoint, str) or not isinstance(token_endpoint, str):
|
|
81
|
+
die(f"OAuth discovery for {host} is missing authorization_endpoint or token_endpoint")
|
|
82
|
+
metadata: dict[str, Any] = dict(body)
|
|
83
|
+
metadata["authorization_endpoint"] = validate_http_url(authorization_endpoint, "OAuth authorization endpoint")
|
|
84
|
+
metadata["token_endpoint"] = validate_http_url(token_endpoint, "OAuth token endpoint")
|
|
85
|
+
return metadata
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TokenRequestError(Exception):
|
|
89
|
+
def __init__(self, status: int, body: str, oauth_error: str | None = None) -> None:
|
|
90
|
+
super().__init__(f"token request failed (HTTP {status}): {body}")
|
|
91
|
+
self.status = status
|
|
92
|
+
self.oauth_error = oauth_error
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def invalidates_refresh_token(self) -> bool:
|
|
96
|
+
return self.oauth_error == "invalid_grant"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_expires_in(value: object) -> float | None:
|
|
100
|
+
if isinstance(value, bool):
|
|
101
|
+
parsed = float("nan")
|
|
102
|
+
elif isinstance(value, (int, float)):
|
|
103
|
+
parsed = float(value)
|
|
104
|
+
elif isinstance(value, str) and value.strip():
|
|
105
|
+
try:
|
|
106
|
+
parsed = float(value)
|
|
107
|
+
except ValueError:
|
|
108
|
+
parsed = float("nan")
|
|
109
|
+
else:
|
|
110
|
+
parsed = float("nan")
|
|
111
|
+
if parsed == parsed and parsed >= 0:
|
|
112
|
+
return parsed
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _token_request(token_endpoint: str, form: dict[str, str]) -> dict[str, object]:
|
|
117
|
+
response = fetch_with_timeout(
|
|
118
|
+
token_endpoint,
|
|
119
|
+
method="POST",
|
|
120
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
121
|
+
body=urlencode(form),
|
|
122
|
+
)
|
|
123
|
+
if not response.ok:
|
|
124
|
+
body = response.text()
|
|
125
|
+
oauth_error: str | None = None
|
|
126
|
+
try:
|
|
127
|
+
parsed: object = json.loads(body)
|
|
128
|
+
if is_record(parsed):
|
|
129
|
+
error_value = parsed.get("error")
|
|
130
|
+
if isinstance(error_value, str):
|
|
131
|
+
oauth_error = error_value
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
pass
|
|
134
|
+
raise TokenRequestError(response.status, body, oauth_error)
|
|
135
|
+
try:
|
|
136
|
+
tokens = response.json()
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
die("token response did not include a valid access token")
|
|
139
|
+
if not is_record(tokens) or not isinstance(tokens.get("access_token"), str) or not tokens.get("access_token"):
|
|
140
|
+
die("token response did not include a valid access token")
|
|
141
|
+
expires_in = _parse_expires_in(tokens.get("expires_in"))
|
|
142
|
+
result: dict[str, object] = {"access_token": tokens["access_token"]}
|
|
143
|
+
if isinstance(tokens.get("refresh_token"), str):
|
|
144
|
+
result["refresh_token"] = tokens["refresh_token"]
|
|
145
|
+
if expires_in is not None:
|
|
146
|
+
result["expires_in"] = expires_in
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _CallbackServer:
|
|
151
|
+
def __init__(self, port: int, expected_state: str) -> None:
|
|
152
|
+
self.expected_state = expected_state
|
|
153
|
+
self.result: dict[str, str] | None = None
|
|
154
|
+
handler = self._handler()
|
|
155
|
+
try:
|
|
156
|
+
self.server = HTTPServer((LOOPBACK_HOST, port), handler)
|
|
157
|
+
except OSError as error:
|
|
158
|
+
die(
|
|
159
|
+
f"cannot listen on {LOOPBACK_HOST}:{port} ({error.strerror or error}) — "
|
|
160
|
+
"stop whatever is using the port and retry"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _handler(self) -> type[BaseHTTPRequestHandler]:
|
|
164
|
+
callback = self
|
|
165
|
+
|
|
166
|
+
class Handler(BaseHTTPRequestHandler):
|
|
167
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
def do_GET(self) -> None:
|
|
171
|
+
parsed = urlparse(self.path)
|
|
172
|
+
if parsed.path != "/callback":
|
|
173
|
+
self.send_response(404)
|
|
174
|
+
self.end_headers()
|
|
175
|
+
return
|
|
176
|
+
params = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
|
177
|
+
if params.get("state") != callback.expected_state:
|
|
178
|
+
self.send_response(400)
|
|
179
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
180
|
+
self.send_header("Connection", "close")
|
|
181
|
+
self.end_headers()
|
|
182
|
+
self.wfile.write(b"<h2>Login callback ignored because its state did not match.</h2>")
|
|
183
|
+
return
|
|
184
|
+
code = params.get("code")
|
|
185
|
+
self.send_response(200)
|
|
186
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
187
|
+
self.send_header("Connection", "close")
|
|
188
|
+
self.end_headers()
|
|
189
|
+
if code:
|
|
190
|
+
body = "<h2>Login complete — you can close this tab and return to the terminal.</h2>"
|
|
191
|
+
else:
|
|
192
|
+
error = escape_html(params.get("error") or "")
|
|
193
|
+
description = escape_html(params.get("error_description") or "")
|
|
194
|
+
body = f"<h2>Login failed.</h2><pre>{error}: {description}</pre>"
|
|
195
|
+
self.wfile.write(body.encode("utf-8"))
|
|
196
|
+
callback.result = params
|
|
197
|
+
|
|
198
|
+
return Handler
|
|
199
|
+
|
|
200
|
+
def wait(self) -> dict[str, str]:
|
|
201
|
+
deadline = time.monotonic() + CALLBACK_TIMEOUT_SECONDS
|
|
202
|
+
try:
|
|
203
|
+
while self.result is None:
|
|
204
|
+
remaining = deadline - time.monotonic()
|
|
205
|
+
if remaining <= 0:
|
|
206
|
+
die("timed out waiting for the browser callback (5 min)")
|
|
207
|
+
self.server.timeout = remaining
|
|
208
|
+
self.server.handle_request()
|
|
209
|
+
finally:
|
|
210
|
+
self.server.server_close()
|
|
211
|
+
result = self.result
|
|
212
|
+
if result is None:
|
|
213
|
+
die("timed out waiting for the browser callback (5 min)")
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def wait_for_callback(port: int, expected_state: str) -> dict[str, str]:
|
|
218
|
+
return _CallbackServer(port, expected_state).wait()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def login(profile: Profile, no_browser: bool, options: dict[str, str] | None = None) -> CacheEntry:
|
|
222
|
+
options = options or {}
|
|
223
|
+
metadata = _discover(profile["host"])
|
|
224
|
+
published_client_id = meaningful(
|
|
225
|
+
metadata.get("insightfactory_cli_client_id")
|
|
226
|
+
if isinstance(metadata.get("insightfactory_cli_client_id"), str)
|
|
227
|
+
else None
|
|
228
|
+
)
|
|
229
|
+
published_organization = meaningful(
|
|
230
|
+
metadata.get("insightfactory_organization_id")
|
|
231
|
+
if isinstance(metadata.get("insightfactory_organization_id"), str)
|
|
232
|
+
else None
|
|
233
|
+
)
|
|
234
|
+
stored_client_id = meaningful(profile.get("client_id"))
|
|
235
|
+
stored_organization = meaningful(profile.get("organization"))
|
|
236
|
+
pinned_client_id = meaningful(options.get("client_id_override"))
|
|
237
|
+
pinned_organization = meaningful(options.get("organization_override"))
|
|
238
|
+
|
|
239
|
+
superseded_client_id = (
|
|
240
|
+
pinned_client_id is None
|
|
241
|
+
and stored_client_id is not None
|
|
242
|
+
and published_client_id is not None
|
|
243
|
+
and stored_client_id != published_client_id
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
client_id = pinned_client_id or (
|
|
247
|
+
published_client_id if superseded_client_id else (stored_client_id or published_client_id)
|
|
248
|
+
)
|
|
249
|
+
organization = pinned_organization or (
|
|
250
|
+
(published_organization or stored_organization)
|
|
251
|
+
if superseded_client_id
|
|
252
|
+
else (stored_organization or published_organization)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if superseded_client_id:
|
|
256
|
+
organization_changed = organization != stored_organization and stored_organization is not None
|
|
257
|
+
sys.stderr.write(
|
|
258
|
+
f"warning: profile '{profile['name']}' has client_id {stored_client_id}, but {profile['host']} "
|
|
259
|
+
f"now advertises {published_client_id}.\n"
|
|
260
|
+
+ (
|
|
261
|
+
f" Its organization {organization} also replaces the profile's {stored_organization}.\n"
|
|
262
|
+
if organization_changed
|
|
263
|
+
else ""
|
|
264
|
+
)
|
|
265
|
+
+ " Using the advertised client. Remove client_id from the profile to silence this,\n"
|
|
266
|
+
+ " or pass --client-id (and --organization) on each login to keep overriding it.\n"
|
|
267
|
+
)
|
|
268
|
+
if not client_id:
|
|
269
|
+
die(
|
|
270
|
+
"no client id: this factory's discovery document does not publish insightfactory_cli_client_id — "
|
|
271
|
+
"pass --client-id (and --organization) with the factory's public client details"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
verifier = base64url(secrets.token_bytes(48))
|
|
275
|
+
challenge = create_pkce_challenge(verifier)
|
|
276
|
+
state = base64url(secrets.token_bytes(24))
|
|
277
|
+
redirect_uri = build_callback_url(profile["callback_port"])
|
|
278
|
+
params = {
|
|
279
|
+
"response_type": "code",
|
|
280
|
+
"client_id": client_id,
|
|
281
|
+
"redirect_uri": redirect_uri,
|
|
282
|
+
"scope": DEFAULT_SCOPE,
|
|
283
|
+
"audience": profile["audience"],
|
|
284
|
+
"state": state,
|
|
285
|
+
"code_challenge": challenge,
|
|
286
|
+
"code_challenge_method": "S256",
|
|
287
|
+
}
|
|
288
|
+
if organization:
|
|
289
|
+
params["organization"] = organization
|
|
290
|
+
|
|
291
|
+
parsed_auth = urlparse(metadata["authorization_endpoint"])
|
|
292
|
+
query = dict(parse_qsl(parsed_auth.query, keep_blank_values=True))
|
|
293
|
+
query.update(params)
|
|
294
|
+
authorization_url = urlunparse(parsed_auth._replace(query=urlencode(query)))
|
|
295
|
+
|
|
296
|
+
callback_server = _CallbackServer(profile["callback_port"], state)
|
|
297
|
+
if no_browser:
|
|
298
|
+
sys.stderr.write(f"Open this URL in a browser logged into the factory:\n\n {authorization_url}\n\n")
|
|
299
|
+
else:
|
|
300
|
+
sys.stderr.write(f"Opening browser for {profile['host']} …\n")
|
|
301
|
+
open_browser(authorization_url)
|
|
302
|
+
sys.stderr.write(f"If the browser did not open, visit:\n {authorization_url}\n")
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
callback_result = callback_server.wait()
|
|
306
|
+
except CliError:
|
|
307
|
+
raise
|
|
308
|
+
except Exception as error:
|
|
309
|
+
die(str(error))
|
|
310
|
+
|
|
311
|
+
if callback_result.get("state") != state:
|
|
312
|
+
die("state mismatch in callback — aborting")
|
|
313
|
+
if not callback_result.get("code"):
|
|
314
|
+
die(f"authorization failed: {callback_result.get('error')}: {callback_result.get('error_description')}")
|
|
315
|
+
|
|
316
|
+
tokens = _token_request(
|
|
317
|
+
metadata["token_endpoint"],
|
|
318
|
+
{
|
|
319
|
+
"grant_type": "authorization_code",
|
|
320
|
+
"client_id": client_id,
|
|
321
|
+
"code": callback_result["code"],
|
|
322
|
+
"code_verifier": verifier,
|
|
323
|
+
"redirect_uri": redirect_uri,
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
return store_tokens(profile["host"], tokens, metadata["token_endpoint"], client_id, replace=True)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _refresh(host: str, entry: CacheEntry, refresh_token: str, client_id: str) -> CacheEntry:
|
|
330
|
+
tokens = _token_request(
|
|
331
|
+
entry["token_endpoint"],
|
|
332
|
+
{
|
|
333
|
+
"grant_type": "refresh_token",
|
|
334
|
+
"client_id": client_id,
|
|
335
|
+
"refresh_token": refresh_token,
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
return store_tokens(host, tokens, entry["token_endpoint"], client_id)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _wait_for_fresh_token(host: str) -> str | None:
|
|
342
|
+
for attempt in range(RECOVERY_POLL_ATTEMPTS):
|
|
343
|
+
entry = load_cache()["tokens"].get(host)
|
|
344
|
+
if entry and is_fresh(entry):
|
|
345
|
+
return entry["access_token"]
|
|
346
|
+
if attempt + 1 < RECOVERY_POLL_ATTEMPTS:
|
|
347
|
+
time.sleep(RECOVERY_POLL_INTERVAL_MS / 1000)
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def get_valid_token(profile: Profile) -> str:
|
|
352
|
+
entry = load_cache()["tokens"].get(profile["host"])
|
|
353
|
+
if not entry:
|
|
354
|
+
die(f"no cached token for {profile['host']} — run: if-cli login -p {profile['name']}")
|
|
355
|
+
if is_fresh(entry):
|
|
356
|
+
return entry["access_token"]
|
|
357
|
+
refresh_token = entry.get("refresh_token")
|
|
358
|
+
if refresh_token:
|
|
359
|
+
client_id = entry.get("client_id")
|
|
360
|
+
if not client_id:
|
|
361
|
+
clear_refresh_token_if_matches(profile["host"], refresh_token)
|
|
362
|
+
die(
|
|
363
|
+
f"could not refresh token for {profile['host']}: no client id cached — "
|
|
364
|
+
f"run: if-cli login -p {profile['name']}"
|
|
365
|
+
)
|
|
366
|
+
try:
|
|
367
|
+
return _refresh(profile["host"], entry, refresh_token, client_id)["access_token"]
|
|
368
|
+
except Exception as error:
|
|
369
|
+
failure: BaseException = error
|
|
370
|
+
if isinstance(error, TokenRequestError) and error.invalidates_refresh_token:
|
|
371
|
+
cleared = clear_refresh_token_if_matches(profile["host"], refresh_token)
|
|
372
|
+
if cleared:
|
|
373
|
+
recovered_access_token = _wait_for_fresh_token(profile["host"])
|
|
374
|
+
if recovered_access_token:
|
|
375
|
+
return recovered_access_token
|
|
376
|
+
else:
|
|
377
|
+
recovered = load_cache()["tokens"].get(profile["host"])
|
|
378
|
+
recovered_refresh = recovered.get("refresh_token") if recovered else None
|
|
379
|
+
recovered_client_id = recovered.get("client_id") if recovered else None
|
|
380
|
+
if recovered and recovered_refresh and recovered_refresh != refresh_token and recovered_client_id:
|
|
381
|
+
try:
|
|
382
|
+
return _refresh(profile["host"], recovered, recovered_refresh, recovered_client_id)[
|
|
383
|
+
"access_token"
|
|
384
|
+
]
|
|
385
|
+
except Exception as retry_error:
|
|
386
|
+
failure = retry_error
|
|
387
|
+
detail = f": {failure}" if isinstance(failure, Exception) else ""
|
|
388
|
+
die(f"could not refresh token for {profile['host']}{detail} — run: if-cli login -p {profile['name']}")
|
|
389
|
+
die(f"token for {profile['host']} expired and no refresh token cached — run: if-cli login -p {profile['name']}")
|
if_cli/runtime.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import NoReturn, TypeGuard
|
|
10
|
+
|
|
11
|
+
_exit_code = 0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CliError(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def die(message: str) -> NoReturn:
|
|
19
|
+
raise CliError(message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def set_exit_code(code: int) -> None:
|
|
23
|
+
global _exit_code
|
|
24
|
+
_exit_code = code
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_exit_code() -> int:
|
|
28
|
+
return _exit_code
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_record(value: object) -> TypeGuard[dict[str, object]]:
|
|
32
|
+
return isinstance(value, dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def jwt_exp(token: str) -> int | None:
|
|
36
|
+
try:
|
|
37
|
+
payload = token.split(".")[1]
|
|
38
|
+
padding = "=" * ((4 - len(payload) % 4) % 4)
|
|
39
|
+
data: object = json.loads(base64.urlsafe_b64decode(payload + padding))
|
|
40
|
+
if not is_record(data):
|
|
41
|
+
return None
|
|
42
|
+
exp = data.get("exp")
|
|
43
|
+
return exp if isinstance(exp, int) else None
|
|
44
|
+
except (IndexError, ValueError, json.JSONDecodeError, TypeError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_expiry(epoch_seconds: int) -> str:
|
|
49
|
+
return datetime.fromtimestamp(epoch_seconds).strftime("%c")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def escape_html(value: str) -> str:
|
|
53
|
+
return (
|
|
54
|
+
value.replace("&", "&")
|
|
55
|
+
.replace("<", "<")
|
|
56
|
+
.replace(">", ">")
|
|
57
|
+
.replace('"', """)
|
|
58
|
+
.replace("'", "'")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def install_broken_pipe_handlers() -> None:
|
|
63
|
+
"""Treat a closed stdout pipe as a normal end of output rather than a crash.
|
|
64
|
+
|
|
65
|
+
Downstream readers such as `if-cli profiles | head -1` close the pipe as
|
|
66
|
+
soon as they have the line they want. Ignoring SIGPIPE turns the subsequent
|
|
67
|
+
write into BrokenPipeError, which run_with_handlers converts into a quiet
|
|
68
|
+
exit that still reports any failure the command already recorded.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
|
72
|
+
except (AttributeError, ValueError, OSError):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def handle_broken_pipe() -> NoReturn:
|
|
77
|
+
try:
|
|
78
|
+
sys.stdout.flush()
|
|
79
|
+
except (BrokenPipeError, OSError):
|
|
80
|
+
pass
|
|
81
|
+
try:
|
|
82
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
83
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
84
|
+
except (OSError, ValueError, AttributeError):
|
|
85
|
+
pass
|
|
86
|
+
# Python 3.14 still flushes the original TextIOWrapper at shutdown. If that
|
|
87
|
+
# wrapper is already in an EPIPE error state, CPython maps the ignored
|
|
88
|
+
# exception to exit status 120 and overwrites SystemExit. Point sys.stdout
|
|
89
|
+
# at a fresh /dev/null stream so the shutdown flush succeeds.
|
|
90
|
+
try:
|
|
91
|
+
sys.stdout = open(os.devnull, "w", encoding="utf-8", errors="replace")
|
|
92
|
+
except OSError:
|
|
93
|
+
pass
|
|
94
|
+
raise SystemExit(get_exit_code() if isinstance(get_exit_code(), int) else 0)
|